diff --git a/docs/guides/project-metrics.md b/docs/guides/project-metrics.md index 1c456f25be..70d101030f 100644 --- a/docs/guides/project-metrics.md +++ b/docs/guides/project-metrics.md @@ -41,7 +41,10 @@ When code runs inside Veryfront, the SDK adds request-scoped labels for `project_id`, `project_slug`, `environment`, and `branch` for preview requests. Preview requests without an explicit branch use `branch="main"`. User code should not provide or trust those labels for isolation; the platform-owned -request context wins. +request context wins. In shared Veryfront runtimes, `service_name`, +`service_version` and `deployment_environment` name the runtime service, its +release and the platform deployment, such as `staging`, that exported the +sample. ## Emit eval metrics diff --git a/src/metrics/index.test.ts b/src/metrics/index.test.ts index 749b357797..a3cf42ca39 100644 --- a/src/metrics/index.test.ts +++ b/src/metrics/index.test.ts @@ -484,6 +484,81 @@ describe("metrics public SDK", () => { ); }); + it("labels hosted project metrics with the runtime's service, release and deployment", async () => { + const requests: RequestInit[] = []; + + await withEnv({ + OTEL_METRICS_ENABLED: "true", + OTEL_SERVICE_NAME: "veryfront-server", + OTEL_SERVICE_VERSION: "20260927085934-9420d79fc0e2", + OTEL_DEPLOYMENT_ENVIRONMENT: "staging", + VERYFRONT_API_BASE_URL: "http://veryfront-api:80", + VERYFRONT_API_INTERNAL_USER: "internal-user", + VERYFRONT_API_INTERNAL_PASS: "internal-pass", + }, async () => { + await withMockFetch( + ((_url: string | URL | Request, init?: RequestInit) => { + requests.push(init ?? {}); + return Promise.resolve(new Response("{}", { status: 200 })); + }) as typeof fetch, + async () => { + runWithTrustedProjectEnv( + { OTEL_SERVICE_NAME: "project-chosen", OTEL_DEPLOYMENT_ENVIRONMENT: "production" }, + { projectId: "project-123", environmentId: "env-1" }, + () => metrics.counter("vf_hosted_metric_total", 1), + ); + await metrics.__flushForTests(); + }, + ); + }); + + const resource = JSON.parse(String(requests[0]?.body)).resourceMetrics[0].resource; + const attributes = Object.fromEntries( + resource.attributes.map((attribute: { key: string; value: { stringValue: string } }) => [ + attribute.key, + attribute.value.stringValue, + ]), + ); + assertEquals(attributes["service.name"], "veryfront-server"); + assertEquals(attributes["service.version"], "20260927085934-9420d79fc0e2"); + assertEquals(attributes["deployment.environment"], "staging"); + }); + + it("reads the hosted runtime identity from OTEL_RESOURCE_ATTRIBUTES", async () => { + const requests: RequestInit[] = []; + + await withEnv({ + OTEL_METRICS_ENABLED: "true", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=veryfront-server,service.version=release-1,deployment.environment.name=staging", + VERYFRONT_API_BASE_URL: "http://veryfront-api:80", + VERYFRONT_API_INTERNAL_USER: "internal-user", + VERYFRONT_API_INTERNAL_PASS: "internal-pass", + }, async () => { + await withMockFetch( + ((_url: string | URL | Request, init?: RequestInit) => { + requests.push(init ?? {}); + return Promise.resolve(new Response("{}", { status: 200 })); + }) as typeof fetch, + async () => { + metrics.counter("vf_hosted_metric_total", 1); + await metrics.__flushForTests(); + }, + ); + }); + + const resource = JSON.parse(String(requests[0]?.body)).resourceMetrics[0].resource; + const attributes = Object.fromEntries( + resource.attributes.map((attribute: { key: string; value: { stringValue: string } }) => [ + attribute.key, + attribute.value.stringValue, + ]), + ); + assertEquals(attributes["service.name"], "veryfront-server"); + assertEquals(attributes["service.version"], "release-1"); + assertEquals(attributes["deployment.environment"], "staging"); + }); + it("does not expose internal metrics credentials to a replaced Base64 encoder", async () => { const originalBtoa = Object.getOwnPropertyDescriptor(globalThis, "btoa"); const observedValues: string[] = []; @@ -1018,7 +1093,7 @@ describe("metrics public SDK", () => { assertEquals(metrics.__getDirectTargetCountForTests(), 17); }); - it("applies tenant target quotas to metrics routed through the internal proxy", async () => { + it("keeps one internal proxy target per project whatever telemetry env the project sets", async () => { await withEnv({ SERVER_ID: "server-1", ENVIRONMENT_IDS: "env-project", @@ -1054,7 +1129,7 @@ describe("metrics public SDK", () => { ); }); - assertEquals(metrics.__getDirectTargetCountForTests(), 17); + assertEquals(metrics.__getDirectTargetCountForTests(), 2); }); it("evicts credential-bearing targets without consulting Array species", async () => { @@ -1071,10 +1146,10 @@ describe("metrics public SDK", () => { await withMockFetch( (() => Promise.resolve(new Response("{}", { status: 200 }))) as typeof fetch, async () => { - for (let index = 0; index < 16; index++) { + for (let index = 0; index < 90; index++) { runWithTrustedProjectEnv( - { OTEL_METRICS_ENABLED: "true", OTEL_SERVICE_NAME: `project-${index}` }, - { projectId: "project-a", environmentId: "env-a" }, + { OTEL_METRICS_ENABLED: "true" }, + { projectId: `project-${index}`, environmentId: `env-${index}` }, () => metrics.counter("vf_project_metric_total", 1), ); } @@ -1089,8 +1164,8 @@ describe("metrics public SDK", () => { }); try { runWithTrustedProjectEnv( - { OTEL_METRICS_ENABLED: "true", OTEL_SERVICE_NAME: "project-new" }, - { projectId: "project-a", environmentId: "env-a" }, + { OTEL_METRICS_ENABLED: "true" }, + { projectId: "project-new", environmentId: "env-new" }, () => metrics.counter("vf_project_metric_total", 1), ); } finally { @@ -1101,6 +1176,7 @@ describe("metrics public SDK", () => { }); assertEquals(speciesCalls, 0); + assertEquals(metrics.__getDirectTargetCountForTests(), 90, "the oldest target was evicted"); }); it("dispatches every target without consulting Promise species", async () => { diff --git a/src/metrics/index.ts b/src/metrics/index.ts index 469b28af5c..6397a51888 100644 --- a/src/metrics/index.ts +++ b/src/metrics/index.ts @@ -49,6 +49,7 @@ interface DirectMetricsTarget { headers: Record; serviceName: string; serviceVersion: string; + deploymentEnvironment: string | undefined; capacityScope: string; internal: boolean; tenantScoped: boolean; @@ -142,6 +143,9 @@ const setHas = Set.prototype.has; const regExpTest = RegExp.prototype.test; const arrayIncludes = Array.prototype.includes; const stringStartsWith = String.prototype.startsWith; +const stringIndexOf = String.prototype.indexOf; +const stringSlice = String.prototype.slice; +const stringTrim = String.prototype.trim; const weakMapDelete = WeakMap.prototype.delete; const weakMapGet = WeakMap.prototype.get; const weakMapSet = WeakMap.prototype.set; @@ -404,26 +408,46 @@ function parseHeaders(headerInput: string | undefined): Record { if (headerInput.startsWith("Authorization=")) { return { Authorization: headerInput.slice("Authorization=".length) }; } - - const result: Record = {}; - for (const part of headerInput.split(",")) { - const [key, ...valueParts] = part.split("="); - if (key && valueParts.length > 0) { - result[key.trim()] = valueParts.join("=").trim(); + return parseKeyValueList(headerInput); +} + +/** Parse the OTel `key=value,key=value` env format. */ +function parseKeyValueList(input: string | undefined): Record { + // Host values are parsed with captured intrinsics into a record without a + // prototype, so project code that patched String or Object never sees them. + const result = apply(objectCreate, Object, [null]) as Record; + if (!input) return result; + let start = 0; + while (start <= input.length) { + let end = apply(stringIndexOf, input, [",", start]) as number; + if (end === -1) end = input.length; + const part = apply(stringSlice, input, [start, end]) as string; + const separator = apply(stringIndexOf, part, ["="]) as number; + if (separator > 0) { + const key = apply(stringTrim, apply(stringSlice, part, [0, separator]), []) as string; + result[key] = apply(stringTrim, apply(stringSlice, part, [separator + 1]), []) as string; } + start = end + 1; } return result; } -function resolveDirectServiceIdentity(): Pick< - DirectMetricsTarget, - "serviceName" | "serviceVersion" -> { +// Shared runtimes read the identity from the host, so hosted project metrics +// name the platform service, release and deployment that emitted them. +function resolveDirectServiceIdentity( + read: (name: string) => string | undefined, +): Pick { + const resource = parseKeyValueList(read("OTEL_RESOURCE_ATTRIBUTES")); return { - serviceName: readEnv("OTEL_SERVICE_NAME") ?? "veryfront", - serviceVersion: readEnv("VERYFRONT_VERSION") ?? - readEnv("RELEASE_VERSION") ?? + serviceName: read("OTEL_SERVICE_NAME") ?? resource["service.name"] ?? "veryfront", + serviceVersion: resource["service.version"] ?? + read("OTEL_SERVICE_VERSION") ?? + read("VERYFRONT_VERSION") ?? + read("RELEASE_VERSION") ?? "unknown", + deploymentEnvironment: resource["deployment.environment.name"] ?? + resource["deployment.environment"] ?? + read("OTEL_DEPLOYMENT_ENVIRONMENT"), }; } @@ -467,7 +491,7 @@ function resolveDirectMetricsTarget(): DirectMetricsTarget | null { readProjectEnv("OTEL_EXPORTER_OTLP_METRICS_HEADERS") ?? readProjectEnv("OTEL_EXPORTER_OTLP_HEADERS"), ), - ...resolveDirectServiceIdentity(), + ...resolveDirectServiceIdentity(readEnv), capacityScope: resolveDirectCapacityScope(), internal: false, tenantScoped: true, @@ -485,7 +509,7 @@ function resolveDirectMetricsTarget(): DirectMetricsTarget | null { readHostEnv("VERYFRONT_API_INTERNAL_PASS") ?? "", ), }, - ...resolveDirectServiceIdentity(), + ...resolveDirectServiceIdentity(readHostEnv), capacityScope: tenantScoped ? resolveDirectCapacityScope() : "internal", internal: true, tenantScoped, @@ -501,7 +525,7 @@ function resolveDirectMetricsTarget(): DirectMetricsTarget | null { readEnv("OTEL_EXPORTER_OTLP_METRICS_HEADERS") ?? readEnv("OTEL_EXPORTER_OTLP_HEADERS"), ), - ...resolveDirectServiceIdentity(), + ...resolveDirectServiceIdentity(readEnv), capacityScope: tenantScoped ? resolveDirectCapacityScope() : "host", internal: false, tenantScoped, @@ -669,6 +693,7 @@ function retainDirectTarget(target: DirectMetricsTarget): string | null { interned.target.url === target.url && interned.target.serviceName === target.serviceName && interned.target.serviceVersion === target.serviceVersion && + interned.target.deploymentEnvironment === target.deploymentEnvironment && interned.target.capacityScope === target.capacityScope && interned.target.internal === target.internal && interned.target.tenantScoped === target.tenantScoped && @@ -887,6 +912,9 @@ function buildDirectOtlpBody( "service.name": target.serviceName, "service.version": target.serviceVersion, "service.instance.id": serviceInstanceId, + ...(target.deploymentEnvironment === undefined + ? {} + : { "deployment.environment": target.deploymentEnvironment }), }), }, scopeMetrics: [{