From 75c7b7cdee1619a2baa0f5e20365bcdbbff4efc6 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 2 Jun 2026 14:42:50 -0700 Subject: [PATCH 1/3] refactor(sdkstats): consolidate shared constants under sdkstats/constants.ts Addresses Radhika's follow-up suggestions on PR #156: - Move OTLP wrapper magic constants (endpoint category, unknown-status sentinel, retryable-network error codes, exception-type labels, OTLP retryable HTTP statuses) into a new src/sdkstats/constants.ts. - Move the wire-format metric-name constants (REQUEST_SUCCESS_NAME / REQUEST_FAILURE_NAME / REQUEST_DURATION_NAME / RETRY_COUNT_NAME / THROTTLE_COUNT_NAME / EXCEPTION_COUNT_NAME), the NETWORK_METRIC_NAMES tuple, and the HTTP status-code buckets out of networkStats.ts into the same constants module. networkStats.ts re-exports them for backwards compatibility. - Have the A365 exporter use the shared A365_ENDPOINT_CATEGORY label and the EXC_TIMEOUT/EXC_NETWORK/EXC_CLIENT exceptionType constants instead of duplicating the literal strings. - Document why we cannot just import StatsbeatCounter from @azure/monitor-opentelemetry-exporter (the enum lives at dist/{esm,commonjs}/export/statsbeat/types and the package's exports field only publishes '.' and './package.json', so the enum is not part of the public surface; we mirror its values and keep them in lockstep). All 923 tests pass, lint clean, build green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/a365/exporter/Agent365Exporter.ts | 16 ++-- src/sdkstats/constants.ts | 113 ++++++++++++++++++++++++++ src/sdkstats/networkStats.ts | 52 ++++++------ src/sdkstats/otlpWrapper.ts | 47 ++--------- 4 files changed, 156 insertions(+), 72 deletions(-) create mode 100644 src/sdkstats/constants.ts diff --git a/src/a365/exporter/Agent365Exporter.ts b/src/a365/exporter/Agent365Exporter.ts index 46fb56b..3262e50 100644 --- a/src/a365/exporter/Agent365Exporter.ts +++ b/src/a365/exporter/Agent365Exporter.ts @@ -36,6 +36,12 @@ import { classifyStatusCode, shortHost, } from "../../sdkstats/index.js"; +import { + A365_ENDPOINT_CATEGORY, + EXC_TIMEOUT, + EXC_NETWORK, + EXC_CLIENT, +} from "../../sdkstats/constants.js"; const DEFAULT_MAX_RETRIES = 3; @@ -288,7 +294,7 @@ export class Agent365Exporter implements SpanExporter { // the URL or re-checking env on every iteration. `endpoint` is the // category label per spec — A365 transmits report endpoint="a365". const recordA365Stats = isSdkStatsEnabled(); - const endpointCategory = "a365"; + const endpointCategory = A365_ENDPOINT_CATEGORY; let host = url; if (recordA365Stats) { host = shortHost(url); @@ -530,11 +536,11 @@ function sleep(ms: number): Promise { function classifyExceptionType(error: unknown): string { if (error instanceof Error) { const name = error.name; - if (name === "AbortError" || name === "TimeoutError") return "Timeout exception"; - if (name === "TypeError") return "Network exception"; - return name || "Client exception"; + if (name === "AbortError" || name === "TimeoutError") return EXC_TIMEOUT; + if (name === "TypeError") return EXC_NETWORK; + return name || EXC_CLIENT; } - return "Client exception"; + return EXC_CLIENT; } /** diff --git a/src/sdkstats/constants.ts b/src/sdkstats/constants.ts new file mode 100644 index 0000000..fb9cb83 --- /dev/null +++ b/src/sdkstats/constants.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Shared constants for the SDKStats Network pipeline. + * + * Centralizes the wire-format metric names, HTTP status-code buckets, + * endpoint category labels, and bounded `exceptionType` strings used by + * the network statsbeat accumulator ({@link ../networkStats.ts}), the + * OTLP exporter wrapper ({@link ../otlpWrapper.ts}), and the A365 + * exporter ({@link ../../a365/exporter/Agent365Exporter.ts}). + * + * Ideally the wire-format metric names would be imported directly from + * the `StatsbeatCounter` enum in `@azure/monitor-opentelemetry-exporter` + * so we have a single source of truth. That package, however, only + * publishes the enum at + * `dist/{esm,commonjs}/export/statsbeat/types.{js,d.ts}` and its + * `package.json#exports` field restricts subpath imports to `.` and + * `./package.json`, so the enum is not part of its public surface. We + * mirror the values here and keep them in lockstep with the upstream + * enum — sending envelopes under any other name returns HTTP 200 but + * the AzMon SDKStats backend doesn't index them. + */ + +// --------------------------------------------------------------------------- +// Wire-format metric names. Must match the `StatsbeatCounter` enum in +// `@azure/monitor-opentelemetry-exporter/dist/{esm,commonjs}/export/statsbeat/types.js`. +// --------------------------------------------------------------------------- + +export const REQUEST_SUCCESS_NAME = "Request_Success_Count"; +export const REQUEST_FAILURE_NAME = "Request_Failure_Count"; +export const REQUEST_DURATION_NAME = "Request_Duration"; +export const RETRY_COUNT_NAME = "Retry_Count"; +export const THROTTLE_COUNT_NAME = "Throttle_Count"; +export const EXCEPTION_COUNT_NAME = "Exception_Count"; + +/** + * Names of registered network SDKStats metrics, in registration order. + * + * @internal + */ +export const NETWORK_METRIC_NAMES = [ + REQUEST_SUCCESS_NAME, + REQUEST_FAILURE_NAME, + REQUEST_DURATION_NAME, + RETRY_COUNT_NAME, + THROTTLE_COUNT_NAME, + EXCEPTION_COUNT_NAME, +] as const; + +export type NetworkMetricName = (typeof NETWORK_METRIC_NAMES)[number]; + +// --------------------------------------------------------------------------- +// HTTP status-code buckets per the Application Insights SDKStats Network +// specification. Used by `classifyStatusCode` and by exporter wrappers that +// need a defensive secondary classification. +// --------------------------------------------------------------------------- + +export const RETRY_STATUSES: ReadonlySet = new Set([ + 401, 403, 408, 429, 500, 502, 503, 504, +]); +export const THROTTLE_STATUSES: ReadonlySet = new Set([402, 439]); +// 206 is handled by the caller (per-envelope breakdown). 307/308 are +// followed by the HTTP client transparently and are not reported. +export const IGNORED_STATUSES: ReadonlySet = new Set([206, 307, 308]); + +/** + * Per the OTLP/HTTP response specification, retryable HTTP status codes + * are 429, 502, 503, and 504. The upstream OTLP delegate normally routes + * these through its `retryable` branch (no status code surfaced), but + * wrappers classify defensively for the rare case the failure branch + * still carries a retryable code (e.g. retries exhausted). + */ +export const OTLP_HTTP_RETRYABLE_STATUSES: ReadonlySet = new Set([429, 502, 503, 504]); + +// --------------------------------------------------------------------------- +// Endpoint category labels. Per spec, `endpoint` is a category label, not +// the destination URL. +// --------------------------------------------------------------------------- + +export const OTLP_ENDPOINT_CATEGORY = "otlp"; +export const A365_ENDPOINT_CATEGORY = "a365"; + +/** + * Sentinel `statusCode` dimension used when the upstream OTLP delegate + * has discarded the original HTTP status code (currently the retryable + * 429/502/503/504 path). Keeps the dimension present per spec. + */ +export const OTLP_UNKNOWN_STATUS = "unknown"; + +// --------------------------------------------------------------------------- +// Bounded set of `exceptionType` labels for `Exception_Count`. +// Cardinality must stay bounded so the SDKStats backend can index it. +// --------------------------------------------------------------------------- + +export const EXC_TIMEOUT = "Timeout exception"; +export const EXC_NETWORK = "Network exception"; +export const EXC_CLIENT = "Client exception"; + +/** + * Node socket error codes that we treat as transient network failures + * when classifying an exception into the `Network exception` bucket. + */ +export const RETRYABLE_NETWORK_ERROR_CODES: ReadonlySet = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "EPIPE", + "ETIMEDOUT", + "EAI_AGAIN", + "ENOTFOUND", + "ENETUNREACH", + "EHOSTUNREACH", +]); diff --git a/src/sdkstats/networkStats.ts b/src/sdkstats/networkStats.ts index a373c6b..27a1ed5 100644 --- a/src/sdkstats/networkStats.ts +++ b/src/sdkstats/networkStats.ts @@ -15,35 +15,35 @@ * distro. */ -// Metric names must match the AzMon SDKStats backend's recognized -// schema (see `StatsbeatCounter` enum in -// `@azure/monitor-opentelemetry-exporter/dist/esm/export/statsbeat/types.js`). -// Sending envelopes under any other name returns HTTP 200 but the -// backend doesn't index them, so they're invisible in the SDKStats -// dashboards. The constants below intentionally match the wire-format -// names — do NOT rename them. -export const REQUEST_SUCCESS_NAME = "Request_Success_Count"; -export const REQUEST_FAILURE_NAME = "Request_Failure_Count"; -export const REQUEST_DURATION_NAME = "Request_Duration"; -export const RETRY_COUNT_NAME = "Retry_Count"; -export const THROTTLE_COUNT_NAME = "Throttle_Count"; -export const EXCEPTION_COUNT_NAME = "Exception_Count"; - -/** - * Names of registered network SDKStats metrics, in registration order. - * - * @internal - */ -export const NETWORK_METRIC_NAMES = [ +// Wire-format metric names and the set of HTTP status-code buckets used +// below live in `./constants.js` so the OTLP/A365 exporter wrappers can +// share a single source of truth. Re-exported here for backwards +// compatibility with existing imports of these symbols from +// `./networkStats.js`. +import { REQUEST_SUCCESS_NAME, REQUEST_FAILURE_NAME, REQUEST_DURATION_NAME, RETRY_COUNT_NAME, THROTTLE_COUNT_NAME, EXCEPTION_COUNT_NAME, -] as const; - -export type NetworkMetricName = (typeof NETWORK_METRIC_NAMES)[number]; + NETWORK_METRIC_NAMES, + RETRY_STATUSES, + THROTTLE_STATUSES, + IGNORED_STATUSES, + type NetworkMetricName, +} from "./constants.js"; + +export { + REQUEST_SUCCESS_NAME, + REQUEST_FAILURE_NAME, + REQUEST_DURATION_NAME, + RETRY_COUNT_NAME, + THROTTLE_COUNT_NAME, + EXCEPTION_COUNT_NAME, + NETWORK_METRIC_NAMES, +}; +export type { NetworkMetricName }; /** * Composite key for an aggregated network SDKStats counter. @@ -143,12 +143,6 @@ export function recordDuration(endpoint: string, host: string, durationMs: numbe */ export type StatusCodeKind = "success" | "retry" | "throttle" | "failure" | "ignored"; -const RETRY_STATUSES = new Set([401, 403, 408, 429, 500, 502, 503, 504]); -const THROTTLE_STATUSES = new Set([402, 439]); -// 206 is handled by the caller (per-envelope breakdown). 307/308 are -// followed by the HTTP client transparently and are not reported. -const IGNORED_STATUSES = new Set([206, 307, 308]); - export function classifyStatusCode(status: number): StatusCodeKind { if (status >= 200 && status < 300 && status !== 206) return "success"; if (IGNORED_STATUSES.has(status)) return "ignored"; diff --git a/src/sdkstats/otlpWrapper.ts b/src/sdkstats/otlpWrapper.ts index 8387909..ca6e5f3 100644 --- a/src/sdkstats/otlpWrapper.ts +++ b/src/sdkstats/otlpWrapper.ts @@ -46,44 +46,15 @@ import { recordDuration, shortHost, } from "./networkStats.js"; - -/** Per spec, `endpoint` is a category label, not the destination URL. */ -const OTLP_ENDPOINT_CATEGORY = "otlp"; - -/** - * Sentinel `statusCode` dimension used when the upstream OTLP delegate - * has discarded the original HTTP status code (currently the retryable - * 429/502/503/504 path). Keeps the dimension present per spec. - */ -const OTLP_UNKNOWN_STATUS = "unknown"; - -/** - * Bounded set of `exceptionType` labels for OTLP `Exception_Count`. - * Cardinality must stay bounded so the SDKStats backend can index it. - */ -const EXC_TIMEOUT = "Timeout exception"; -const EXC_NETWORK = "Network exception"; -const EXC_CLIENT = "Client exception"; - -const RETRYABLE_NETWORK_ERROR_CODES = new Set([ - "ECONNRESET", - "ECONNREFUSED", - "EPIPE", - "ETIMEDOUT", - "EAI_AGAIN", - "ENOTFOUND", - "ENETUNREACH", - "EHOSTUNREACH", -]); - -/** - * Per the OTLP/HTTP response specification, retryable HTTP status codes - * are 429, 502, 503, and 504. The upstream delegate normally routes - * these through its `retryable` branch (no status code surfaced), but - * we classify defensively here for the rare case the failure branch - * still carries a retryable code (e.g. retries exhausted). - */ -const OTLP_HTTP_RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); +import { + OTLP_ENDPOINT_CATEGORY, + OTLP_UNKNOWN_STATUS, + OTLP_HTTP_RETRYABLE_STATUSES, + RETRYABLE_NETWORK_ERROR_CODES, + EXC_TIMEOUT, + EXC_NETWORK, + EXC_CLIENT, +} from "./constants.js"; interface ErrorWithCode { code?: unknown; From 767512854e7335f3d4195a57cac6a2b3f21eeb63 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 2 Jun 2026 15:00:44 -0700 Subject: [PATCH 2/3] docs(sdkstats/constants): fix broken @link paths and clarify why StatsbeatCounter cannot be imported - {@link} paths were written as if from a parent of src/sdkstats/; corrected to be relative to src/sdkstats/constants.ts itself (./networkStats, ./otlpWrapper, ../a365/exporter/Agent365Exporter). - Expanded the StatsbeatCounter rationale with the exact TS2307 error produced under our moduleResolution=NodeNext config and pointed at the upstream Azure SDK repo for the public-export ask. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/sdkstats/constants.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/sdkstats/constants.ts b/src/sdkstats/constants.ts index fb9cb83..cb7d6ca 100644 --- a/src/sdkstats/constants.ts +++ b/src/sdkstats/constants.ts @@ -6,20 +6,25 @@ * * Centralizes the wire-format metric names, HTTP status-code buckets, * endpoint category labels, and bounded `exceptionType` strings used by - * the network statsbeat accumulator ({@link ../networkStats.ts}), the - * OTLP exporter wrapper ({@link ../otlpWrapper.ts}), and the A365 - * exporter ({@link ../../a365/exporter/Agent365Exporter.ts}). + * the network statsbeat accumulator ({@link ./networkStats}), the OTLP + * exporter wrapper ({@link ./otlpWrapper}), and the A365 exporter + * ({@link ../a365/exporter/Agent365Exporter}). * * Ideally the wire-format metric names would be imported directly from * the `StatsbeatCounter` enum in `@azure/monitor-opentelemetry-exporter` - * so we have a single source of truth. That package, however, only - * publishes the enum at - * `dist/{esm,commonjs}/export/statsbeat/types.{js,d.ts}` and its - * `package.json#exports` field restricts subpath imports to `.` and - * `./package.json`, so the enum is not part of its public surface. We - * mirror the values here and keep them in lockstep with the upstream - * enum — sending envelopes under any other name returns HTTP 200 but - * the AzMon SDKStats backend doesn't index them. + * so we have a single source of truth. That enum is currently shipped at + * `dist/{esm,commonjs}/export/statsbeat/types.{js,d.ts}`, but the + * package's `package.json#exports` field only publishes `.` and + * `./package.json`, so under our `moduleResolution: NodeNext` config a + * direct `import { StatsbeatCounter } from + * "@azure/monitor-opentelemetry-exporter/dist/esm/export/statsbeat/types.js"` + * fails with `TS2307: Cannot find module … or its corresponding type + * declarations`. Until the exporter exposes the enum from its public + * entry point (tracked upstream in + * https://github.com/Azure/azure-sdk-for-js, sdk/monitor/monitor-opentelemetry-exporter) + * we mirror the values here and keep them in lockstep — sending envelopes + * under any other name returns HTTP 200 but the AzMon SDKStats backend + * doesn't index them. */ // --------------------------------------------------------------------------- From b9f9e854c3872ecae79598dbd30653aab56df317 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 2 Jun 2026 15:24:50 -0700 Subject: [PATCH 3/3] test(sdkstats): consume new shared constants in tests instead of duplicating string literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the four SDKStats test files (networkStats, otlpWrapper, metrics, agent365NetworkStats) to import and use the constants now exposed from sdkstats/constants.ts (A365_ENDPOINT_CATEGORY, OTLP_ENDPOINT_CATEGORY, OTLP_UNKNOWN_STATUS, OTLP_HTTP_RETRYABLE_STATUSES, EXC_TIMEOUT/NETWORK/CLIENT) instead of duplicating the literal strings inline. The single test that intentionally pins the wire-format metric-name strings to their literal values (networkStats.test.ts \xposes all six SDKStats network metric names\) is preserved so a constant rename still fails loudly. Also rewrites the OTLP_HTTP_RETRYABLE_STATUSES iteration to drive the loop directly from the shared set so adding/removing a status updates both the wrapper and the test in lockstep. 923 tests pass, lint clean, build green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../unit/a365/agent365NetworkStats.test.ts | 7 ++- test/internal/unit/sdkstats/metrics.test.ts | 25 +++++---- .../unit/sdkstats/networkStats.test.ts | 56 ++++++++++--------- .../unit/sdkstats/otlpWrapper.test.ts | 34 ++++++----- 4 files changed, 69 insertions(+), 53 deletions(-) diff --git a/test/internal/unit/a365/agent365NetworkStats.test.ts b/test/internal/unit/a365/agent365NetworkStats.test.ts index 9b91f67..f0d558f 100644 --- a/test/internal/unit/a365/agent365NetworkStats.test.ts +++ b/test/internal/unit/a365/agent365NetworkStats.test.ts @@ -16,6 +16,7 @@ import { _resetAllForTest, drain, } from "../../../../src/sdkstats/networkStats.js"; +import { A365_ENDPOINT_CATEGORY } from "../../../../src/sdkstats/constants.js"; import { _resetA365LoggerForTest } from "../../../../src/a365/logging.js"; const TENANT_ID = "tenant-11111111-1111-1111-1111-111111111111"; @@ -134,7 +135,7 @@ describe("Agent365Exporter network SDKStats", () => { const failures = drain(REQUEST_FAILURE_NAME); expect(failures.size).toBe(1); const [key, count] = [...failures.entries()][0]; - expect(key[0]).toBe("a365"); + expect(key[0]).toBe(A365_ENDPOINT_CATEGORY); expect(key[2]).toBe("404"); expect(count).toBe(1); }); @@ -189,7 +190,7 @@ describe("Agent365Exporter network SDKStats", () => { const exceptions = drain(EXCEPTION_COUNT_NAME); expect(exceptions.size).toBe(1); const [key, count] = [...exceptions.entries()][0]; - expect(key[0]).toBe("a365"); + expect(key[0]).toBe(A365_ENDPOINT_CATEGORY); // 4 attempts (initial + 3 retries) each throw. expect(count).toBe(4); }); @@ -203,7 +204,7 @@ describe("Agent365Exporter network SDKStats", () => { const durations = drain(REQUEST_DURATION_NAME); expect(durations.size).toBe(1); const [key, avg] = [...durations.entries()][0]; - expect(key[0]).toBe("a365"); + expect(key[0]).toBe(A365_ENDPOINT_CATEGORY); expect(avg).toBeGreaterThanOrEqual(0); }); }); diff --git a/test/internal/unit/sdkstats/metrics.test.ts b/test/internal/unit/sdkstats/metrics.test.ts index 030dbe2..bee96ff 100644 --- a/test/internal/unit/sdkstats/metrics.test.ts +++ b/test/internal/unit/sdkstats/metrics.test.ts @@ -19,6 +19,7 @@ import { recordSuccess, recordThrottle, } from "../../../../src/sdkstats/networkStats.js"; +import { A365_ENDPOINT_CATEGORY, EXC_TIMEOUT } from "../../../../src/sdkstats/constants.js"; import { FEATURE_TYPE_FEATURE, FEATURE_TYPE_INSTRUMENTATION, @@ -175,7 +176,7 @@ describe("sdkstats/metrics", () => { setSdkStatsInstrumentation(SdkStatsInstrumentation.MONGODB); // Drop a network counter so a request_success_count observation will fire. _resetNetworkStatsForTest(); - recordSuccess("a365", "contoso.example.com"); + recordSuccess(A365_ENDPOINT_CATEGORY, "contoso.example.com"); const { PeriodicExportingMetricReader } = await import("@opentelemetry/sdk-metrics"); const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); @@ -205,8 +206,8 @@ describe("sdkstats/metrics", () => { describe("network gauges (default mode)", () => { it("emits one observation per drained key, attaches endpoint + host, and clears after collection", async () => { _resetNetworkStatsForTest(); - recordSuccess("a365", "a365.example.com"); - recordSuccess("a365", "a365.example.com"); + recordSuccess(A365_ENDPOINT_CATEGORY, "a365.example.com"); + recordSuccess(A365_ENDPOINT_CATEGORY, "a365.example.com"); const { PeriodicExportingMetricReader } = await import("@opentelemetry/sdk-metrics"); const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); @@ -229,7 +230,7 @@ describe("sdkstats/metrics", () => { const success = byName(REQUEST_SUCCESS_NAME); expect(success).toHaveLength(1); expect(success[0].value).toBe(2); - expect(success[0].attributes.endpoint).toBe("a365"); + expect(success[0].attributes.endpoint).toBe(A365_ENDPOINT_CATEGORY); expect(success[0].attributes.host).toBe("a365.example.com"); expect(success[0].attributes.statusCode).toBeUndefined(); @@ -247,12 +248,12 @@ describe("sdkstats/metrics", () => { it("emits failure/retry/throttle/exception observations with the appropriate dimension and an avg duration", async () => { _resetNetworkStatsForTest(); - recordFailure("a365", "westus", 404); - recordRetry("a365", "westus", 503); - recordThrottle("a365", "westus", 439); - recordException("a365", "westus", "Timeout exception"); - recordDuration("a365", "westus", 100); - recordDuration("a365", "westus", 200); + recordFailure(A365_ENDPOINT_CATEGORY, "westus", 404); + recordRetry(A365_ENDPOINT_CATEGORY, "westus", 503); + recordThrottle(A365_ENDPOINT_CATEGORY, "westus", 439); + recordException(A365_ENDPOINT_CATEGORY, "westus", EXC_TIMEOUT); + recordDuration(A365_ENDPOINT_CATEGORY, "westus", 100); + recordDuration(A365_ENDPOINT_CATEGORY, "westus", 200); const { PeriodicExportingMetricReader } = await import("@opentelemetry/sdk-metrics"); const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); @@ -283,12 +284,12 @@ describe("sdkstats/metrics", () => { expect(throttles[0].attributes.statusCode).toBe("439"); const exceptions = byName(EXCEPTION_COUNT_NAME); - expect(exceptions[0].attributes.exceptionType).toBe("Timeout exception"); + expect(exceptions[0].attributes.exceptionType).toBe(EXC_TIMEOUT); const durations = byName(REQUEST_DURATION_NAME); expect(durations).toHaveLength(1); expect(durations[0].value).toBe(150); - expect(durations[0].attributes.endpoint).toBe("a365"); + expect(durations[0].attributes.endpoint).toBe(A365_ENDPOINT_CATEGORY); expect(durations[0].attributes.host).toBe("westus"); // Duration has no statusCode / exceptionType dimension. expect(durations[0].attributes.statusCode).toBeUndefined(); diff --git a/test/internal/unit/sdkstats/networkStats.test.ts b/test/internal/unit/sdkstats/networkStats.test.ts index 703c1fc..8e97cb4 100644 --- a/test/internal/unit/sdkstats/networkStats.test.ts +++ b/test/internal/unit/sdkstats/networkStats.test.ts @@ -22,6 +22,12 @@ import { recordThrottle, shortHost, } from "../../../../src/sdkstats/networkStats.js"; +import { + A365_ENDPOINT_CATEGORY, + EXC_NETWORK, + EXC_TIMEOUT, + OTLP_ENDPOINT_CATEGORY, +} from "../../../../src/sdkstats/constants.js"; describe("sdkstats/networkStats", () => { beforeEach(() => { @@ -46,11 +52,11 @@ describe("sdkstats/networkStats", () => { }); it("records failure/retry/throttle counts keyed by (endpoint, host, statusCode)", () => { - recordFailure("a365", "westus", 400); - recordFailure("a365", "westus", 400); - recordFailure("a365", "westus", 404); - recordRetry("a365", "westus", 503); - recordThrottle("a365", "westus", 429); + recordFailure(A365_ENDPOINT_CATEGORY, "westus", 400); + recordFailure(A365_ENDPOINT_CATEGORY, "westus", 400); + recordFailure(A365_ENDPOINT_CATEGORY, "westus", 404); + recordRetry(A365_ENDPOINT_CATEGORY, "westus", 503); + recordThrottle(A365_ENDPOINT_CATEGORY, "westus", 429); const failures = drain(REQUEST_FAILURE_NAME); expect(failures.size).toBe(2); @@ -58,30 +64,30 @@ describe("sdkstats/networkStats", () => { expect(failures.get([...failures.keys()].find((k) => k[2] === "404")!)).toBe(1); const retries = drain(RETRY_COUNT_NAME); - expect([...retries.entries()]).toEqual([[["a365", "westus", "503"], 1]]); + expect([...retries.entries()]).toEqual([[[A365_ENDPOINT_CATEGORY, "westus", "503"], 1]]); const throttles = drain(THROTTLE_COUNT_NAME); - expect([...throttles.entries()]).toEqual([[["a365", "westus", "429"], 1]]); + expect([...throttles.entries()]).toEqual([[[A365_ENDPOINT_CATEGORY, "westus", "429"], 1]]); }); it("records exception counts keyed by (endpoint, host, exceptionType)", () => { - recordException("otlp", "collector", "Timeout exception"); - recordException("otlp", "collector", "Timeout exception"); - recordException("otlp", "collector", "Network exception"); + recordException(OTLP_ENDPOINT_CATEGORY, "collector", EXC_TIMEOUT); + recordException(OTLP_ENDPOINT_CATEGORY, "collector", EXC_TIMEOUT); + recordException(OTLP_ENDPOINT_CATEGORY, "collector", EXC_NETWORK); const exceptions = drain(EXCEPTION_COUNT_NAME); expect(exceptions.size).toBe(2); const entries = [...exceptions.entries()].sort(([a], [b]) => a[2].localeCompare(b[2])); expect(entries).toEqual([ - [["otlp", "collector", "Network exception"], 1], - [["otlp", "collector", "Timeout exception"], 2], + [[OTLP_ENDPOINT_CATEGORY, "collector", EXC_NETWORK], 1], + [[OTLP_ENDPOINT_CATEGORY, "collector", EXC_TIMEOUT], 2], ]); }); it("recordDuration averages recorded durations per (endpoint, host) on drain", () => { - recordDuration("a365", "westus", 100); - recordDuration("a365", "westus", 300); - recordDuration("a365", "eastus", 50); + recordDuration(A365_ENDPOINT_CATEGORY, "westus", 100); + recordDuration(A365_ENDPOINT_CATEGORY, "westus", 300); + recordDuration(A365_ENDPOINT_CATEGORY, "eastus", 50); const durations = drain(REQUEST_DURATION_NAME); expect(durations.size).toBe(2); @@ -94,9 +100,9 @@ describe("sdkstats/networkStats", () => { }); it("recordDuration ignores negative or non-finite values", () => { - recordDuration("a365", "westus", -1); - recordDuration("a365", "westus", NaN); - recordDuration("a365", "westus", Infinity); + recordDuration(A365_ENDPOINT_CATEGORY, "westus", -1); + recordDuration(A365_ENDPOINT_CATEGORY, "westus", NaN); + recordDuration(A365_ENDPOINT_CATEGORY, "westus", Infinity); expect(drain(REQUEST_DURATION_NAME).size).toBe(0); }); @@ -131,27 +137,27 @@ describe("sdkstats/networkStats", () => { }); it("accumulates success counts per (endpoint, host) and reports keys as two-element tuples", () => { - recordSuccess("otlp", "a.example.com"); - recordSuccess("otlp", "a.example.com"); - recordSuccess("otlp", "b.example.com"); + recordSuccess(OTLP_ENDPOINT_CATEGORY, "a.example.com"); + recordSuccess(OTLP_ENDPOINT_CATEGORY, "a.example.com"); + recordSuccess(OTLP_ENDPOINT_CATEGORY, "b.example.com"); const snap = drain(REQUEST_SUCCESS_NAME); expect(snap.size).toBe(2); const entries = Array.from(snap.entries()).sort(([a], [b]) => a[1].localeCompare(b[1])); - expect(entries[0][0]).toEqual(["otlp", "a.example.com"]); + expect(entries[0][0]).toEqual([OTLP_ENDPOINT_CATEGORY, "a.example.com"]); expect(entries[0][1]).toBe(2); - expect(entries[1][0]).toEqual(["otlp", "b.example.com"]); + expect(entries[1][0]).toEqual([OTLP_ENDPOINT_CATEGORY, "b.example.com"]); expect(entries[1][1]).toBe(1); }); it("drain() empties the bucket atomically — second drain returns an empty map", () => { - recordSuccess("otlp", "a.example.com"); + recordSuccess(OTLP_ENDPOINT_CATEGORY, "a.example.com"); expect(drain(REQUEST_SUCCESS_NAME).size).toBe(1); expect(drain(REQUEST_SUCCESS_NAME).size).toBe(0); }); it("_resetAllForTest() clears every bucket", () => { - recordSuccess("otlp", "a.example.com"); + recordSuccess(OTLP_ENDPOINT_CATEGORY, "a.example.com"); _resetAllForTest(); for (const name of NETWORK_METRIC_NAMES) { expect(drain(name).size).toBe(0); diff --git a/test/internal/unit/sdkstats/otlpWrapper.test.ts b/test/internal/unit/sdkstats/otlpWrapper.test.ts index 68a0536..df48b6e 100644 --- a/test/internal/unit/sdkstats/otlpWrapper.test.ts +++ b/test/internal/unit/sdkstats/otlpWrapper.test.ts @@ -22,12 +22,20 @@ import { _resetAllForTest, drain, } from "../../../../src/sdkstats/networkStats.js"; +import { + EXC_CLIENT, + EXC_NETWORK, + EXC_TIMEOUT, + OTLP_ENDPOINT_CATEGORY, + OTLP_HTTP_RETRYABLE_STATUSES, + OTLP_UNKNOWN_STATUS, +} from "../../../../src/sdkstats/constants.js"; // `shortHost("https://collector.example.com:4318")` strips the first // path component, so the dimension value the wrappers record is just -// "collector". `endpoint` is the category label ("otlp"). +// "collector". `endpoint` is the category label (OTLP_ENDPOINT_CATEGORY). const HOST = "collector"; -const ENDPOINT = "otlp"; +const ENDPOINT = OTLP_ENDPOINT_CATEGORY; function setEndpointEnv(): void { process.env.OTEL_EXPORTER_OTLP_ENDPOINT = `https://collector.example.com:4318`; @@ -94,7 +102,7 @@ describe("sdkstats/otlpWrapper", () => { const exceptions = drain(EXCEPTION_COUNT_NAME); expect(exceptions.size).toBe(1); const [key, count] = [...exceptions.entries()][0]; - expect(key).toEqual([ENDPOINT, HOST, "Client exception"]); + expect(key).toEqual([ENDPOINT, HOST, EXC_CLIENT]); expect(count).toBe(1); // Duration is recorded regardless of outcome. @@ -117,7 +125,7 @@ describe("sdkstats/otlpWrapper", () => { }); it("records Retry_Count when the HTTP status code is a retryable OTLP code (429/502/503/504)", async () => { - for (const status of [429, 502, 503, 504]) { + for (const status of OTLP_HTTP_RETRYABLE_STATUSES) { _resetAllForTest(); const httpError = Object.assign(new Error(""), { name: "OTLPExporterError", @@ -147,18 +155,18 @@ describe("sdkstats/otlpWrapper", () => { await new Promise((resolve) => wrapper.export([], () => resolve())); const retries = drain(RETRY_COUNT_NAME); - expect([...retries.entries()]).toEqual([[[ENDPOINT, HOST, "unknown"], 1]]); + expect([...retries.entries()]).toEqual([[[ENDPOINT, HOST, OTLP_UNKNOWN_STATUS], 1]]); expect(drain(EXCEPTION_COUNT_NAME).size).toBe(0); }); it("records Exception_Count with a bounded type for timeouts and network errors", async () => { const cases: Array<[Error, string]> = [ - [Object.assign(new Error("aborted"), { name: "AbortError" }), "Timeout exception"], - [Object.assign(new Error("timed out"), { name: "TimeoutError" }), "Timeout exception"], - [new Error("Request timed out"), "Timeout exception"], - [new TypeError("fetch failed"), "Network exception"], - [Object.assign(new Error("conn refused"), { code: "ECONNREFUSED" }), "Network exception"], - [Object.assign(new Error("dns"), { code: "ENOTFOUND" }), "Network exception"], + [Object.assign(new Error("aborted"), { name: "AbortError" }), EXC_TIMEOUT], + [Object.assign(new Error("timed out"), { name: "TimeoutError" }), EXC_TIMEOUT], + [new Error("Request timed out"), EXC_TIMEOUT], + [new TypeError("fetch failed"), EXC_NETWORK], + [Object.assign(new Error("conn refused"), { code: "ECONNREFUSED" }), EXC_NETWORK], + [Object.assign(new Error("dns"), { code: "ENOTFOUND" }), EXC_NETWORK], ]; for (const [err, expected] of cases) { @@ -184,7 +192,7 @@ describe("sdkstats/otlpWrapper", () => { expect(drain(REQUEST_FAILURE_NAME).size).toBe(0); expect(drain(RETRY_COUNT_NAME).size).toBe(0); const exc = drain(EXCEPTION_COUNT_NAME); - expect([...exc.keys()][0]).toEqual([ENDPOINT, HOST, "Client exception"]); + expect([...exc.keys()][0]).toEqual([ENDPOINT, HOST, EXC_CLIENT]); }); it("records a request duration on SUCCESS", async () => { @@ -229,7 +237,7 @@ describe("sdkstats/otlpWrapper", () => { const exceptions = drain(EXCEPTION_COUNT_NAME); expect(exceptions.size).toBe(1); - expect([...exceptions.keys()][0]).toEqual([ENDPOINT, HOST, "Client exception"]); + expect([...exceptions.keys()][0]).toEqual([ENDPOINT, HOST, EXC_CLIENT]); expect(drain(REQUEST_DURATION_NAME).size).toBe(1); }); });