@oxy.so/telemetry 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Operational metrics for Oxy services, published as CloudWatch Embedded Metric
3
+ * Format (EMF) log lines.
4
+ *
5
+ * WHY EMF ON STDOUT: every Oxy backend runs on ECS with the `awslogs` driver, and
6
+ * CloudWatch Logs extracts metrics from any log event shaped as EMF — no agent,
7
+ * no sidecar, no scraper, no IAM grant beyond the logging the task already has.
8
+ * A registry that only an HTTP scrape can read (Prometheus) measures into a void
9
+ * when nothing scrapes it, which is how Mention's counters lost every value on
10
+ * every deploy.
11
+ *
12
+ * WHY AGGREGATED: values are folded in memory and flushed once per interval as
13
+ * ONE line per (metric, label set), not one line per observation. A latency is
14
+ * kept as a distribution (`Values`/`Counts`), so CloudWatch still answers
15
+ * SampleCount, p50 and p99 for it — the request COUNT is the distribution's
16
+ * SampleCount, so a service needs no separate counter for it.
17
+ *
18
+ * WHY MOST LABELS ARE NOT DIMENSIONS: every distinct combination of dimension
19
+ * values is a billed custom metric. Route x method as dimensions once measured
20
+ * 405 metrics and ~80 % of the account's CloudWatch bill (Mention #1334). So a
21
+ * label becomes a DIMENSION only when a metric definition names it in
22
+ * `dimensionSets`, and only labels with a small, closed value set belong there.
23
+ * Every other label is written as a plain property of the line: free to store,
24
+ * and grouped in Logs Insights (`stats sum(RequestLatency.Count) by Route`).
25
+ */
26
+ export type MetricUnit = 'Count' | 'Milliseconds' | 'Seconds' | 'Bytes' | 'Percent' | 'Count/Second' | 'None';
27
+ /**
28
+ * `sum` adds every value in the interval (a counter), `distribution` keeps the
29
+ * values for percentiles (a latency or a size), and `gauge` keeps the last one.
30
+ */
31
+ export type MetricKind = 'sum' | 'distribution' | 'gauge';
32
+ export interface MetricDefinition {
33
+ /** CloudWatch metric name; also the field holding its value on the line. */
34
+ readonly name: string;
35
+ readonly unit: MetricUnit;
36
+ readonly kind: MetricKind;
37
+ /**
38
+ * Label names promoted to CloudWatch dimensions. Each inner list is one
39
+ * dimension set, published IN ADDITION to the always-present `[Service]`
40
+ * set. Only closed, small value sets belong here: each distinct combination
41
+ * is a billed custom metric.
42
+ */
43
+ readonly dimensionSets?: readonly (readonly string[])[];
44
+ }
45
+ export type MetricLabels = Readonly<Record<string, string>>;
46
+ export interface EmfPublisherOptions {
47
+ /** CloudWatch namespace, e.g. `Oxy/Api`. */
48
+ readonly namespace: string;
49
+ /** Value of the `Service` dimension every metric carries. */
50
+ readonly service: string;
51
+ /** Flush period. Default 60 s, CloudWatch's standard resolution. */
52
+ readonly flushIntervalMs?: number;
53
+ /**
54
+ * Distinct (metric, label set) series held per interval. A label value that
55
+ * traffic can mint must not be able to grow memory or log volume without
56
+ * bound, so past this many every NEW series is folded into one whose labels
57
+ * all read `other`. Default 2000.
58
+ */
59
+ readonly maxSeries?: number;
60
+ /** Where each line goes. Default: one line on stdout. */
61
+ readonly write?: (line: string) => void;
62
+ /** Clock, for tests. */
63
+ readonly now?: () => number;
64
+ }
65
+ export interface EmfPublisher {
66
+ /** Fold one observation into the current interval. Never throws. */
67
+ record(metric: MetricDefinition, value: number, labels?: MetricLabels): void;
68
+ /** Write the current interval now and start a new one. */
69
+ flush(): void;
70
+ /** Flush and stop the timer, for graceful shutdown. */
71
+ stop(): void;
72
+ }
73
+ /** One HTTP request: SampleCount is the request count, percentiles the latency. */
74
+ export declare const HTTP_REQUEST_LATENCY: MetricDefinition;
75
+ /** One request a rate limiter refused (429), per calling application. */
76
+ export declare const HTTP_RATE_LIMITED: MetricDefinition;
77
+ /** `2xx`, `4xx`, … — a closed set, safe as a dimension. */
78
+ export declare function statusClass(status: number): string;
79
+ /**
80
+ * `createEmfPublisher` on ECS, `null` everywhere else (a laptop, CI, a test):
81
+ * the gate oxy-api and Mention each repeated. `env` is injectable for tests.
82
+ */
83
+ export declare function createEmfPublisherIfDeployed(options: EmfPublisherOptions, env?: Readonly<Record<string, string | undefined>>): EmfPublisher | null;
84
+ export declare function createEmfPublisher(options: EmfPublisherOptions): EmfPublisher;
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -1,12 +1,18 @@
1
- import type { ActivityAggregate, AnonymousActivityMetadata } from './index.js';
1
+ import { type ActivityAggregate, type AnonymousActivityMetadata } from './index.js';
2
2
  export declare const DEFAULT_PRESENCE_BUCKET_MS = 30000;
3
+ /** @deprecated Unused by any Oxy repo; `presenceKeys` already encodes the window. Removal is planned for the next major. */
3
4
  export declare const DEFAULT_ACTIVE_CLIENT_WINDOW_MS = 60000;
4
5
  export type TelemetryHeaderValue = string | readonly string[] | undefined;
5
6
  export type TelemetryHeaders = Readonly<Record<string, TelemetryHeaderValue>>;
7
+ /**
8
+ * @deprecated Unused by any Oxy repo (presence counting lives in the API). No
9
+ * replacement; removal is planned for the next major.
10
+ */
6
11
  export interface ActivityCardinalityAdapter {
7
12
  add(key: string, activityId: string, ttlSeconds: number): Promise<void>;
8
13
  count(keys: readonly string[]): Promise<number>;
9
14
  }
15
+ /** @deprecated Unused by any Oxy repo; see `activityFlow`. */
10
16
  export interface ActivityFlow {
11
17
  sourceRegion?: string;
12
18
  service: string;
@@ -14,9 +20,28 @@ export interface ActivityFlow {
14
20
  }
15
21
  export declare function normalizeEdgePop(value: TelemetryHeaderValue): string | undefined;
16
22
  export declare function normalizeActivityId(value: TelemetryHeaderValue): string | undefined;
23
+ /**
24
+ * The collector's source region for a normalised edge PoP (`edge-<pop>`), the
25
+ * one derivation every reporter shares. `undefined` when there is no PoP.
26
+ */
27
+ export declare function sourceRegionFromEdgePop(edgePop: string | undefined): string | undefined;
17
28
  export declare function metadataFromHeaders(headers: TelemetryHeaders): AnonymousActivityMetadata;
29
+ /**
30
+ * @deprecated Unused by any Oxy repo. For the bounded activity category of a
31
+ * path use `trafficType` from `@oxy.so/telemetry/collector`. Removal is planned
32
+ * for the next major.
33
+ */
18
34
  export declare function serviceFromPath(path: string): string;
35
+ /**
36
+ * @deprecated Unused by any Oxy repo; `createTrafficCollector` keys its own
37
+ * flows. Removal is planned for the next major.
38
+ */
19
39
  export declare function activityFlow(sourceRegion: string | undefined, service: string): ActivityFlow;
40
+ /**
41
+ * @deprecated Unused outside `presenceKeys`; call `presenceKeys` for the
42
+ * current and previous bucket keys. Removal is planned for the next major.
43
+ */
20
44
  export declare function presenceBucket(observedAt: number, bucketMs?: number): number;
21
45
  export declare function presenceKeys(sourceRegion: string, observedAt: number, bucketMs?: number): readonly [string, string];
46
+ /** @deprecated Unused by any Oxy repo; use `ActivityAggregate` from `@oxy.so/telemetry`. */
22
47
  export type ActivityBucket = ActivityAggregate;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oxy.so/telemetry",
3
- "version": "2.0.0",
4
- "description": "Framework-agnostic anonymous activity telemetry primitives for Oxy services.",
3
+ "version": "2.2.0",
4
+ "description": "Framework-agnostic anonymous activity telemetry and CloudWatch EMF metrics primitives for Oxy services.",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",
7
7
  "module": "dist/esm/index.js",
@@ -19,6 +19,9 @@
19
19
  ],
20
20
  "socket": [
21
21
  "dist/types/socket.d.ts"
22
+ ],
23
+ "metrics": [
24
+ "dist/types/metrics.d.ts"
22
25
  ]
23
26
  }
24
27
  },
@@ -88,12 +91,24 @@
88
91
  "types": "./dist/types/socket.d.ts",
89
92
  "default": "./dist/cjs/socket.js"
90
93
  }
94
+ },
95
+ "./metrics": {
96
+ "import": {
97
+ "types": "./dist/types/metrics.d.ts",
98
+ "default": "./dist/esm/metrics.js"
99
+ },
100
+ "require": {
101
+ "types": "./dist/types/metrics.d.ts",
102
+ "default": "./dist/cjs/metrics.js"
103
+ }
91
104
  }
92
105
  },
93
106
  "files": [
94
107
  "dist",
95
- "src"
108
+ "src",
109
+ "!dist/.oxy-source-digest"
96
110
  ],
111
+ "oxySourceStamp": "dist",
97
112
  "repository": {
98
113
  "type": "git",
99
114
  "url": "git+https://github.com/OxyHQ/oxy.git",
@@ -106,23 +121,22 @@
106
121
  "node": ">=18.0.0"
107
122
  },
108
123
  "scripts": {
109
- "build": "bun run clean && bun run build:cjs && bun run build:esm && bun run build:types",
124
+ "build": "bun run clean && bun run build:cjs && bun run build:esm && bun run build:types && node -e \"const p='../app-preset/metro/source-stamp.js';require('node:fs').existsSync(p)&&require(p).writeStamp(process.cwd())\"",
110
125
  "build:cjs": "tsc -p tsconfig.cjs.json && node scripts/mark-cjs.mjs",
111
126
  "build:esm": "tsc -p tsconfig.esm.json",
112
- "build:types": "tsc -p tsconfig.types.json",
127
+ "build:types": "tsc -p tsconfig.types.json && node scripts/mark-types-cjs.mjs",
113
128
  "clean": "rm -rf dist",
114
129
  "typescript": "tsc --noEmit",
115
130
  "test": "jest",
116
- "lint": "biome lint --error-on-warnings ./src",
117
- "prepublishOnly": "node ../../scripts/assert-bun-publish.mjs && bun run typescript && bun run test && bun run build"
131
+ "lint": "biome lint --error-on-warnings ./src"
118
132
  },
119
133
  "devDependencies": {
120
134
  "@types/jest": "^30.0.0",
121
- "jest": "^30.5.1",
122
- "jest-environment-jsdom": "^30.5.1",
123
- "socket.io": "^4.8.1",
124
- "socket.io-client": "^4.8.1",
125
- "ts-jest": "^29.4.12",
126
- "typescript": "^5.9.2"
135
+ "jest": "^30.5.2",
136
+ "jest-environment-jsdom": "^30.5.2",
137
+ "socket.io": "^4.8.4",
138
+ "socket.io-client": "^4.8.4",
139
+ "ts-jest": "^29.4.15",
140
+ "typescript": "~6.0.3"
127
141
  }
128
142
  }
@@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
3
3
  import {
4
4
  createTrafficCollector,
5
5
  instrumentTrafficFetch,
6
+ resolveOxyServiceEndpoint,
6
7
  trafficMiddleware,
7
8
  trafficType,
8
9
  type TrafficAggregate,
@@ -197,4 +198,20 @@ describe('ecosystem activity collector', () => {
197
198
  expect(trafficType('/api/v1/uploads/private-id?token=secret')).toBe('media');
198
199
  expect(trafficType('/v1/chat/completions')).toBe('ai');
199
200
  });
201
+
202
+ it('attributes the Gemies API to gemies and keeps Alia’s API as alia', () => {
203
+ expect(resolveOxyServiceEndpoint(new URL('https://api.gemies.ai/v1/chat'))).toEqual({
204
+ service: 'gemies',
205
+ region: 'us-west-2',
206
+ });
207
+ expect(resolveOxyServiceEndpoint(new URL('https://api.alia.onl/v1/chat'))?.service).toBe(
208
+ 'alia',
209
+ );
210
+ // Exact host only: neither the web app nor a lookalike claims the service.
211
+ expect(resolveOxyServiceEndpoint(new URL('https://gemies.ai/'))).toBeUndefined();
212
+ expect(
213
+ resolveOxyServiceEndpoint(new URL('https://api.gemies.ai.evil.example/')),
214
+ ).toBeUndefined();
215
+ expect(resolveOxyServiceEndpoint(new URL('http://api.gemies.ai/'))).toBeUndefined();
216
+ });
200
217
  });
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createEmfPublisher, type MetricDefinition } from '../metrics';
4
+
5
+ // The cross-language contract: Kaana's Go `internal/emf` runs the same file.
6
+ // Numbers JSON cannot carry travel as the strings below.
7
+ const SENTINELS: Record<string, number> = {
8
+ NaN: Number.NaN,
9
+ Infinity: Number.POSITIVE_INFINITY,
10
+ '-Infinity': Number.NEGATIVE_INFINITY,
11
+ };
12
+
13
+ interface GoldenCase {
14
+ name: string;
15
+ definition: MetricDefinition;
16
+ service: string;
17
+ namespace: string;
18
+ now: number;
19
+ maxSeries?: number;
20
+ observations: { labels: Record<string, string>; value: number | string }[];
21
+ expectedLines: unknown[];
22
+ }
23
+
24
+ const cases: GoldenCase[] = JSON.parse(
25
+ readFileSync(join(__dirname, '..', '..', 'fixtures', 'emf-golden.json'), 'utf8'),
26
+ );
27
+
28
+ describe('EMF golden fixture', () => {
29
+ it('is not empty and has unique case names', () => {
30
+ expect(cases.length).toBeGreaterThan(0);
31
+ expect(new Set(cases.map((entry) => entry.name)).size).toBe(cases.length);
32
+ });
33
+
34
+ it.each(cases.map((entry) => [entry.name, entry] as const))('%s', (_name, entry) => {
35
+ const lines: string[] = [];
36
+ const publisher = createEmfPublisher({
37
+ namespace: entry.namespace,
38
+ service: entry.service,
39
+ write: (line) => lines.push(line),
40
+ now: () => entry.now,
41
+ ...(entry.maxSeries === undefined ? {} : { maxSeries: entry.maxSeries }),
42
+ });
43
+ for (const { labels, value } of entry.observations) {
44
+ publisher.record(
45
+ entry.definition,
46
+ typeof value === 'string' ? SENTINELS[value] : value,
47
+ labels,
48
+ );
49
+ }
50
+ publisher.stop();
51
+ expect(lines.map((line) => JSON.parse(line))).toEqual(entry.expectedLines);
52
+ });
53
+ });
@@ -0,0 +1,45 @@
1
+ import { createEmfPublisher, HTTP_REQUEST_LATENCY, HTTP_RATE_LIMITED } from '../metrics';
2
+
3
+ describe('EMF publisher', () => {
4
+ it('folds an interval into one EMF line per series with bounded dimensions', () => {
5
+ const lines: string[] = [];
6
+ const publisher = createEmfPublisher({
7
+ namespace: 'Oxy/Test',
8
+ service: 'svc',
9
+ write: (line) => lines.push(line),
10
+ now: () => 1_000,
11
+ maxSeries: 2,
12
+ });
13
+ const labels = { Route: '/users/:id', StatusClass: '2xx', Caller: 'anonymous' };
14
+ for (const ms of [10, 10, 200]) publisher.record(HTTP_REQUEST_LATENCY, ms, labels);
15
+ publisher.record(HTTP_RATE_LIMITED, 1, { Caller: 'service:app' });
16
+ // A third series exceeds maxSeries and folds into `other`.
17
+ publisher.record(HTTP_RATE_LIMITED, 1, { Caller: 'service:another' });
18
+ publisher.stop();
19
+
20
+ const [latency, limited, overflow] = lines.map((line) => JSON.parse(line));
21
+ expect(latency._aws).toEqual({
22
+ Timestamp: 1_000,
23
+ CloudWatchMetrics: [
24
+ {
25
+ Namespace: 'Oxy/Test',
26
+ Dimensions: [['Service'], ['Service', 'StatusClass'], ['Service', 'Caller']],
27
+ Metrics: [{ Name: 'RequestLatency', Unit: 'Milliseconds' }],
28
+ },
29
+ ],
30
+ });
31
+ expect(latency).toMatchObject({ Service: 'svc', Route: '/users/:id', StatusClass: '2xx' });
32
+ // Values are ~5 % buckets; Count/Sum/Min/Max stay exact.
33
+ expect(latency.RequestLatency.Values).toEqual([9.91, 204]);
34
+ expect(latency.RequestLatency).toMatchObject({
35
+ Counts: [2, 1],
36
+ Max: 200,
37
+ Min: 10,
38
+ Count: 3,
39
+ Sum: 220,
40
+ });
41
+ expect(limited).toMatchObject({ Caller: 'service:app', RateLimited: 1 });
42
+ expect(overflow).toMatchObject({ Caller: 'other', RateLimited: 1 });
43
+ expect(lines).toHaveLength(3);
44
+ });
45
+ });
@@ -0,0 +1,184 @@
1
+ /** @jest-environment node */
2
+ import { isObservedPath } from '../collector';
3
+ import {
4
+ OXY_ACTIVITY_ID_HEADER,
5
+ OXY_EDGE_REGION_HEADER,
6
+ OXY_REGION_HEADER,
7
+ OXY_SOURCE_REGION_HEADER,
8
+ OXY_TELEMETRY_EXPOSED_HEADERS,
9
+ OXY_TELEMETRY_REQUEST_HEADERS,
10
+ } from '../index';
11
+ import { createEmfPublisherIfDeployed } from '../metrics';
12
+ import { metadataFromHeaders, sourceRegionFromEdgePop } from '../server';
13
+
14
+ describe('telemetry header lists', () => {
15
+ it('request headers are exactly the headers a browser sends', () => {
16
+ expect([...OXY_TELEMETRY_REQUEST_HEADERS]).toEqual([
17
+ OXY_ACTIVITY_ID_HEADER,
18
+ OXY_EDGE_REGION_HEADER,
19
+ ]);
20
+ expect(OXY_TELEMETRY_REQUEST_HEADERS).not.toContain(OXY_SOURCE_REGION_HEADER);
21
+ });
22
+
23
+ it('exposed headers are exactly the response headers a browser reads', () => {
24
+ expect([...OXY_TELEMETRY_EXPOSED_HEADERS]).toEqual([OXY_REGION_HEADER]);
25
+ });
26
+
27
+ it('the server reads the same headers the constants name', () => {
28
+ expect(
29
+ metadataFromHeaders({
30
+ [OXY_EDGE_REGION_HEADER.toLowerCase()]: 'MAD',
31
+ [OXY_ACTIVITY_ID_HEADER.toLowerCase()]: 'anonymous-runtime-a1',
32
+ }),
33
+ ).toEqual({ edgePop: 'mad', activityId: 'anonymous-runtime-a1' });
34
+ });
35
+ });
36
+
37
+ // The pre-change behaviour, copied verbatim from origin/main collector.ts
38
+ // (middleware lines 119-124, instrumentTrafficFetch lines 200-202).
39
+ const legacyInboundExcluded = (path: string): boolean =>
40
+ /^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|platform-infrastructure|infra-status)(?:\/|$)/.test(
41
+ path,
42
+ ) ||
43
+ path.startsWith('/internal/activity') ||
44
+ path.startsWith('/auth/service-token') ||
45
+ path.startsWith('/cdn-cgi/');
46
+ const legacyOutboundExcluded = (pathname: string): boolean =>
47
+ pathname.startsWith('/internal/activity') ||
48
+ pathname.startsWith('/auth/service-token') ||
49
+ pathname === '/platform-infrastructure';
50
+
51
+ const PATHS = [
52
+ '/',
53
+ '/users/me',
54
+ '/api/v1/users',
55
+ '/auth/session',
56
+ '/healthy',
57
+ '/readyz',
58
+ '/cdn-cgi',
59
+ '/health',
60
+ '/health/deep',
61
+ '/health?x=1',
62
+ '/api/health',
63
+ '/api/health/x',
64
+ '/ready',
65
+ '/api/ready',
66
+ '/live',
67
+ '/api/live',
68
+ '/platform-stats',
69
+ '/platform-stats/stream',
70
+ '/api/platform-stats',
71
+ '/platform-activity',
72
+ '/platform-activity/x',
73
+ '/api/platform-activity',
74
+ '/infra-status',
75
+ '/infra-status/x',
76
+ '/api/infra-status',
77
+ '/platform-infrastructure',
78
+ '/platform-infrastructure/',
79
+ '/platform-infrastructure/x',
80
+ '/api/platform-infrastructure',
81
+ '/api/platform-infrastructure/x',
82
+ '/platform-infrastructures',
83
+ '/api/api/health',
84
+ '/internal/activity',
85
+ '/internal/activity/batch',
86
+ '/internal/activityx',
87
+ '/auth/service-token',
88
+ '/auth/service-token/x',
89
+ '/auth/service-tokens',
90
+ '/cdn-cgi/',
91
+ '/cdn-cgi/trace',
92
+ '/v1/health',
93
+ ];
94
+
95
+ describe('isObservedPath', () => {
96
+ it.each(PATHS)('%s matches the pre-change inbound behaviour', (path) => {
97
+ expect(isObservedPath(path, 'inbound')).toBe(!legacyInboundExcluded(path));
98
+ });
99
+
100
+ it.each(PATHS)('%s matches the pre-change outbound behaviour', (path) => {
101
+ expect(isObservedPath(path, 'outbound')).toBe(!legacyOutboundExcluded(path));
102
+ });
103
+
104
+ it('pins the excluded sets explicitly in both directions', () => {
105
+ const excluded = (direction: 'inbound' | 'outbound') =>
106
+ PATHS.filter((path) => !isObservedPath(path, direction));
107
+ expect(excluded('outbound')).toEqual([
108
+ '/platform-infrastructure',
109
+ '/internal/activity',
110
+ '/internal/activity/batch',
111
+ '/internal/activityx',
112
+ '/auth/service-token',
113
+ '/auth/service-token/x',
114
+ '/auth/service-tokens',
115
+ ]);
116
+ expect(excluded('inbound')).toEqual(PATHS.filter(legacyInboundExcluded));
117
+ // Excluded inbound only today; recorded outbound. Unifying is an owner decision.
118
+ expect(excluded('inbound').filter((path) => isObservedPath(path, 'outbound'))).toContain(
119
+ '/platform-stats',
120
+ );
121
+ });
122
+ });
123
+
124
+ describe('sourceRegionFromEdgePop', () => {
125
+ it('prefixes a PoP and passes absence through', () => {
126
+ expect(sourceRegionFromEdgePop('mad')).toBe('edge-mad');
127
+ expect(sourceRegionFromEdgePop(undefined)).toBeUndefined();
128
+ expect(sourceRegionFromEdgePop('')).toBeUndefined();
129
+ });
130
+ });
131
+
132
+ describe('createEmfPublisherIfDeployed', () => {
133
+ const options = { namespace: 'Oxy/Test', service: 'svc', write: () => {} };
134
+
135
+ it('returns null off ECS', () => {
136
+ expect(createEmfPublisherIfDeployed(options, {})).toBeNull();
137
+ expect(createEmfPublisherIfDeployed(options, { ECS_CONTAINER_METADATA_URI_V4: '' })).toBeNull();
138
+ });
139
+
140
+ it('returns a publisher on ECS', () => {
141
+ const publisher = createEmfPublisherIfDeployed(options, {
142
+ ECS_CONTAINER_METADATA_URI_V4: 'http://169.254.170.2/v4/task',
143
+ });
144
+ expect(publisher).not.toBeNull();
145
+ publisher?.stop();
146
+ });
147
+
148
+ it('reads process.env by default', () => {
149
+ const previous = process.env.ECS_CONTAINER_METADATA_URI_V4;
150
+ delete process.env.ECS_CONTAINER_METADATA_URI_V4;
151
+ try {
152
+ expect(createEmfPublisherIfDeployed(options)).toBeNull();
153
+ } finally {
154
+ if (previous !== undefined) process.env.ECS_CONTAINER_METADATA_URI_V4 = previous;
155
+ }
156
+ });
157
+ });
158
+
159
+ describe('instrumentTrafficFetch exclusion', () => {
160
+ it('skips control requests and still records outbound calls to inbound-excluded paths', async () => {
161
+ const { createTrafficCollector, instrumentTrafficFetch } = await import('../collector');
162
+ const published: unknown[][] = [];
163
+ const collector = createTrafficCollector(async (events) => {
164
+ published.push(events);
165
+ });
166
+ const fetcher = jest.fn(async () => new Response('ok')) as unknown as typeof fetch;
167
+ const observed = instrumentTrafficFetch(
168
+ fetcher,
169
+ collector,
170
+ 'svc',
171
+ 'us-west-2',
172
+ () => undefined,
173
+ );
174
+ await observed('https://example.com/internal/activity');
175
+ await observed('https://example.com/auth/service-token');
176
+ await observed('https://example.com/platform-infrastructure');
177
+ await collector.flush();
178
+ expect(published).toHaveLength(0);
179
+ // Recorded outbound today, though excluded inbound.
180
+ await observed('https://example.com/platform-stats');
181
+ await collector.flush();
182
+ expect(published).toHaveLength(1);
183
+ });
184
+ });
package/src/browser.ts CHANGED
@@ -60,6 +60,12 @@ function eligibleLocation(location: Pick<Location, 'hostname' | 'protocol'> | un
60
60
  );
61
61
  }
62
62
 
63
+ /**
64
+ * @deprecated No Oxy repo constructs its own runtime: use the module-level
65
+ * `getBrowserActivityIdHeader`, `getBrowserEdgeRegionHeader` and
66
+ * `peekBrowserEdgeRegionHeader`. The factory remains the test seam. Removal from
67
+ * the public surface is planned for the next major.
68
+ */
63
69
  export function createBrowserTelemetry(options: BrowserTelemetryOptions = {}): BrowserTelemetry {
64
70
  let activityId: string | null = null;
65
71
  let activityIdCreatedAt = 0;
@@ -169,5 +175,10 @@ export const getBrowserEdgeRegionHeader = (): Promise<Record<string, string>> =>
169
175
  export const peekBrowserEdgeRegionHeader = (): Record<string, string> =>
170
176
  defaultBrowserTelemetry.peekEdgeRegionHeader();
171
177
 
178
+ /**
179
+ * @deprecated Unused by any Oxy repo. `@oxy.so/core` sends
180
+ * `peekBrowserEdgeRegionHeader()` and `getBrowserActivityIdHeader()` per request.
181
+ * Removal is planned for the next major.
182
+ */
172
183
  export const getBrowserTelemetryHeaders = (): Promise<Record<string, string>> =>
173
184
  defaultBrowserTelemetry.getHeaders();