@oxy.so/telemetry 2.1.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.
package/README.md CHANGED
@@ -6,7 +6,8 @@ Redis, React, React Native or Expo.
6
6
 
7
7
  ## Entry points
8
8
 
9
- - `@oxy.so/telemetry` — shared header names and aggregate interfaces.
9
+ - `@oxy.so/telemetry` — shared header names, the CORS header lists and the wire
10
+ types (`TrafficAggregate`, `ActivityAggregate`, `InfrastructureSnapshot`).
10
11
  - `@oxy.so/telemetry/browser` — ephemeral browser metadata.
11
12
  - `@oxy.so/telemetry/collector` — bounded HTTP/custom-transport flow aggregation.
12
13
  - `@oxy.so/telemetry/server` — pure validation, route bucketing and cardinality
@@ -20,12 +21,19 @@ side effects.
20
21
  ## Browser metadata
21
22
 
22
23
  ```ts
23
- import { getBrowserTelemetryHeaders } from '@oxy.so/telemetry/browser';
24
+ import { getBrowserActivityIdHeader, peekBrowserEdgeRegionHeader } from '@oxy.so/telemetry/browser';
24
25
 
25
- const headers = await getBrowserTelemetryHeaders();
26
+ const headers = { ...peekBrowserEdgeRegionHeader(), ...getBrowserActivityIdHeader() };
26
27
  await fetch('https://api.oxy.so/session/status', { headers });
27
28
  ```
28
29
 
30
+ This is what `@oxy.so/core` sends on every request: the peek never waits on the
31
+ trace. `getBrowserEdgeRegionHeader()` is the awaiting variant.
32
+
33
+ A CORS allow-list for these headers is `OXY_TELEMETRY_REQUEST_HEADERS` (from the
34
+ root entry), and `OXY_TELEMETRY_EXPOSED_HEADERS` for `Access-Control-Expose-Headers`;
35
+ import them instead of repeating the header names.
36
+
29
37
  `X-Oxy-Activity-Id` is generated in memory for the current page runtime and
30
38
  rotates every five minutes. It is not placed in a cookie, local storage,
31
39
  session storage, IndexedDB or a URL. Closing or reloading the page loses it.
@@ -35,34 +43,32 @@ from the same-origin `/cdn-cgi/trace` endpoint. The helper reads no IP field and
35
43
  returns, logs and persists none of the rest of the trace response. Discovery is
36
44
  cached for 15 seconds per runtime (including failed discovery), has a one-second timeout and is disabled on loopback.
37
45
 
38
- Use `createBrowserTelemetry()` to create an isolated runtime or inject clock,
39
- crypto, location and trace-fetch dependencies in tests.
46
+ `createBrowserTelemetry()` builds an isolated runtime with injected clock,
47
+ crypto, location and trace-fetch dependencies; it is the test seam.
40
48
 
41
49
  ## Server primitives
42
50
 
43
51
  ```ts
44
52
  import {
45
- activityFlow,
46
53
  metadataFromHeaders,
47
54
  presenceKeys,
48
- serviceFromPath,
49
- type ActivityCardinalityAdapter,
55
+ sourceRegionFromEdgePop,
50
56
  } from '@oxy.so/telemetry/server';
51
57
 
52
58
  const metadata = metadataFromHeaders(request.headers);
53
- const sourceRegion = metadata.edgePop ? `edge-${metadata.edgePop}` : undefined;
54
- const flow = activityFlow(sourceRegion, serviceFromPath(request.path));
59
+ const sourceRegion = sourceRegionFromEdgePop(metadata.edgePop); // 'edge-mad' | undefined
55
60
  const keys = sourceRegion ? presenceKeys(sourceRegion, Date.now()) : [];
56
61
  ```
57
62
 
58
63
  Validation accepts a three-letter PoP and a 16–64 character opaque activity ID.
59
- It ignores IP and forwarding headers. `serviceFromPath` keeps only a bounded,
60
- static first route segment, so IDs and full paths cannot enter aggregates.
61
-
62
- `ActivityCardinalityAdapter` describes the minimal storage boundary for an
63
- implementation such as Redis HyperLogLog. This package does not instantiate a
64
+ It ignores IP and forwarding headers. This package does not instantiate a
64
65
  store, keep server-global maps, start timers or send realtime events.
65
66
 
67
+ `serviceFromPath`, `activityFlow`, `presenceBucket`, `ActivityCardinalityAdapter`,
68
+ `ActivityBucket`, `DEFAULT_ACTIVE_CLIENT_WINDOW_MS`, `getBrowserTelemetryHeaders`
69
+ and `createBrowserTelemetry` are deprecated: nothing in Oxy uses them and they go in
70
+ the next major.
71
+
66
72
  ## Active-client definition
67
73
 
68
74
  An active client is one distinct ephemeral activity ID observed for an edge PoP
@@ -106,8 +112,10 @@ follow the actual movement, even when two services share one region.
106
112
  identity after route authentication. `instrumentTrafficFetch` observes actual
107
113
  outbound calls; internal destination regions come from the peer's `X-Oxy-Region`
108
114
  response, not a guessed IP location. Unlocated peers remain unlocated. Health,
109
- telemetry collection, infrastructure snapshots and service-token minting are
110
- excluded from observation where applicable to prevent control traffic feedback.
115
+ telemetry collection, infrastructure snapshots, service-token minting and
116
+ `/cdn-cgi/` are excluded from observation to prevent control traffic feedback;
117
+ `isObservedPath(path, direction)` (reading the `UNOBSERVED_PATHS` table) is the one predicate, so an adapter that does its own
118
+ interception imports it instead of copying a pattern.
111
119
  Custom transports can call the collector's `record` method explicitly.
112
120
 
113
121
  Each collector retains at most 512 distinct pending flows and publishes chunks
@@ -116,7 +124,8 @@ durable retry queue. These are live operational observations, not billing or
116
124
  lossless historical request totals. No dashboard viewer is needed to collect.
117
125
 
118
126
  Realtime transport publishes aggregate buckets, not individual metadata. A
119
- socket event may carry `ActivityAggregate`: request count, approximate active
127
+ socket event carries `ActivityAggregate` (a `TrafficAggregate` plus
128
+ `activeClients`; the infrastructure snapshot is `InfrastructureSnapshot`): request count, approximate active
120
129
  clients, source/target regions, service and window timestamps. Transport and
121
130
  fan-out are application concerns and intentionally absent from this package.
122
131
 
@@ -131,11 +140,15 @@ installed in each service before this can represent the whole ecosystem.
131
140
  The authenticated publisher is `createEcosystemTraffic` from `@oxy.so/core/server`.
132
141
  It exposes HTTP middleware, `wrapFetch`, `record` for explicit transport events,
133
142
  and `stop` for graceful removal. It requires a known infrastructure region (or
134
- explicit infrastructure coordinates) and one of three ways to authenticate: an
135
- `OXY_ACTIVITY_API_KEY`/`OXY_ACTIVITY_API_SECRET` pair, an
136
- `OXY_SERVICE_API_KEY`/`OXY_SERVICE_API_SECRET` pair, or no credential at all
137
- where the process can attest its workload identity (ADR 0026). Credentials, user
138
- metadata and paths never enter public snapshots.
143
+ explicit infrastructure coordinates) and registered temporary workload identity
144
+ for own service publication ([ADR 0049](../../docs/adr/0049-first-party-platform-authority.md)).
145
+ The shared OxyServer performs attestation and live binding checks. A process can
146
+ supply `workloadCredentials` from `createAwsWorkloadCredentialProvider()`
147
+ (`@oxy.so/core/server/aws`) for the supported temporary AWS provider chain.
148
+ There is no activity-key or service-key fallback for own publication. External
149
+ customer API credentials remain separate and do not become first-party authority.
150
+ Credentials, user metadata and paths never enter public snapshots. Activity's
151
+ internal/external `scope` field classifies traffic; it is not an OAuth permission.
139
152
 
140
153
  ## Operational metrics (CloudWatch EMF)
141
154
 
@@ -166,6 +179,27 @@ Route, method, table and every other label stay plain properties on the line:
166
179
  `maxSeries` (default 2000) series are held per interval; past that, new series
167
180
  fold into one whose labels read `other`.
168
181
 
182
+ On ECS only, `createEmfPublisherIfDeployed(options)` returns the same publisher
183
+ and `null` elsewhere (it checks `ECS_CONTAINER_METADATA_URI_V4`), so a service
184
+ does not repeat that gate.
185
+
186
+ ### Conformance fixture
187
+
188
+ `fixtures/emf-golden.json` is the cross-language contract for the EMF line format.
189
+ Each case holds a metric `definition`, `service`, `namespace`, a fixed `now`
190
+ (the line's `Timestamp`), optional `maxSeries`, the `observations`
191
+ (`{labels, value}`; the strings `"NaN"`, `"Infinity"` and `"-Infinity"` stand for
192
+ the non-finite numbers JSON cannot carry) and the `expectedLines` the publisher
193
+ must write on flush, in series first-seen order. Lines are compared as parsed
194
+ JSON, not as bytes. `src/__tests__/emfGolden.test.ts` runs every case through the
195
+ real publisher.
196
+
197
+ Kaana's Go `internal/emf` must consume the same file (a vendored copy that a
198
+ Kaana test keeps byte-identical to this one) and pass every case; change the
199
+ fixture here first. Label values are truncated by UTF-16 code units, so a Go
200
+ implementation counts UTF-16 units, not bytes or runes. The fixture avoids a cut
201
+ inside a surrogate pair, whose result is a lone surrogate that UTF-8 cannot carry.
202
+
169
203
  ## Development
170
204
 
171
205
  ```bash
@@ -30,6 +30,12 @@ function eligibleLocation(location) {
30
30
  location.hostname !== '127.0.0.1' &&
31
31
  location.hostname !== '[::1]');
32
32
  }
33
+ /**
34
+ * @deprecated No Oxy repo constructs its own runtime: use the module-level
35
+ * `getBrowserActivityIdHeader`, `getBrowserEdgeRegionHeader` and
36
+ * `peekBrowserEdgeRegionHeader`. The factory remains the test seam. Removal from
37
+ * the public surface is planned for the next major.
38
+ */
33
39
  function createBrowserTelemetry(options = {}) {
34
40
  let activityId = null;
35
41
  let activityIdCreatedAt = 0;
@@ -125,5 +131,10 @@ const getBrowserEdgeRegionHeader = () => defaultBrowserTelemetry.getEdgeRegionHe
125
131
  exports.getBrowserEdgeRegionHeader = getBrowserEdgeRegionHeader;
126
132
  const peekBrowserEdgeRegionHeader = () => defaultBrowserTelemetry.peekEdgeRegionHeader();
127
133
  exports.peekBrowserEdgeRegionHeader = peekBrowserEdgeRegionHeader;
134
+ /**
135
+ * @deprecated Unused by any Oxy repo. `@oxy.so/core` sends
136
+ * `peekBrowserEdgeRegionHeader()` and `getBrowserActivityIdHeader()` per request.
137
+ * Removal is planned for the next major.
138
+ */
128
139
  const getBrowserTelemetryHeaders = () => defaultBrowserTelemetry.getHeaders();
129
140
  exports.getBrowserTelemetryHeaders = getBrowserTelemetryHeaders;
@@ -1,12 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UNOBSERVED_PATHS = void 0;
3
4
  exports.trafficType = trafficType;
5
+ exports.isObservedPath = isObservedPath;
4
6
  exports.createTrafficCollector = createTrafficCollector;
5
7
  exports.trafficMiddleware = trafficMiddleware;
6
8
  exports.instrumentTrafficFetch = instrumentTrafficFetch;
7
9
  exports.resolveOxyServiceEndpoint = resolveOxyServiceEndpoint;
8
10
  exports.infrastructureLocation = infrastructureLocation;
9
11
  exports.normalizeInfrastructureRegion = normalizeInfrastructureRegion;
12
+ const index_js_1 = require("./index.js");
10
13
  const server_js_1 = require("./server.js");
11
14
  const TYPES = {
12
15
  auth: 'identity',
@@ -48,6 +51,35 @@ function trafficType(path) {
48
51
  parts.shift();
49
52
  return TYPES[parts[0] ?? ''] ?? 'platform';
50
53
  }
54
+ /**
55
+ * Every path a reporter does not observe, in ONE table. Each entry says which
56
+ * directions it applies to: the inbound and outbound sets differ today (an
57
+ * outbound call to a probe or to `/platform-stats` IS recorded), and that is
58
+ * preserved exactly. Unifying them is a separate, deliberate decision.
59
+ */
60
+ exports.UNOBSERVED_PATHS = [
61
+ {
62
+ directions: 'inbound',
63
+ matches: (path) => /^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|infra-status)(?:\/|$)/.test(path),
64
+ },
65
+ {
66
+ directions: 'inbound',
67
+ matches: (path) => /^\/(?:api\/)?platform-infrastructure(?:\/|$)/.test(path),
68
+ },
69
+ { directions: 'outbound', matches: (path) => path === '/platform-infrastructure' },
70
+ { directions: 'both', matches: (path) => path.startsWith('/internal/activity') },
71
+ { directions: 'both', matches: (path) => path.startsWith('/auth/service-token') },
72
+ { directions: 'inbound', matches: (path) => path.startsWith('/cdn-cgi/') },
73
+ ];
74
+ /**
75
+ * Is this path part of what a reporter observes in this direction? Collection
76
+ * itself, service-token minting and (inbound) probes and the dashboard
77
+ * transport are control traffic: observing them would feed back into the very
78
+ * activity they carry.
79
+ */
80
+ function isObservedPath(path, direction) {
81
+ return !exports.UNOBSERVED_PATHS.some((entry) => (entry.directions === 'both' || entry.directions === direction) && entry.matches(path));
82
+ }
51
83
  function createTrafficCollector(publish, now = Date.now) {
52
84
  const pending = new Map();
53
85
  let flushing = null;
@@ -88,13 +120,10 @@ function createTrafficCollector(publish, now = Date.now) {
88
120
  }
89
121
  function trafficMiddleware(collector, service, region) {
90
122
  return (request, response, next) => {
91
- response.setHeader?.('X-Oxy-Region', region);
123
+ response.setHeader?.(index_js_1.OXY_REGION_HEADER, region);
92
124
  const path = request.path ?? request.url ?? '/';
93
125
  // Never report collection itself, probes or the dashboard transport.
94
- if (/^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|platform-infrastructure|infra-status)(?:\/|$)/.test(path) ||
95
- path.startsWith('/internal/activity') ||
96
- path.startsWith('/auth/service-token') ||
97
- path.startsWith('/cdn-cgi/')) {
126
+ if (!isObservedPath(path, 'inbound')) {
98
127
  next();
99
128
  return;
100
129
  }
@@ -108,12 +137,10 @@ function trafficMiddleware(collector, service, region) {
108
137
  .replace(/[^a-z0-9-]/g, '-')
109
138
  .slice(0, 40);
110
139
  const internal = Boolean(name);
111
- const forwardedRegion = request.headers['x-oxy-source-region'];
140
+ const forwardedRegion = request.headers[index_js_1.OXY_SOURCE_REGION_HEADER.toLowerCase()];
112
141
  const peerRegion = internal
113
142
  ? normalizeInfrastructureRegion(forwardedRegion)
114
- : edgePop
115
- ? `edge-${edgePop}`
116
- : undefined;
143
+ : (0, server_js_1.sourceRegionFromEdgePop)(edgePop);
117
144
  return {
118
145
  region,
119
146
  service,
@@ -157,9 +184,7 @@ function instrumentTrafficFetch(fetcher, collector, service, region, resolveEndp
157
184
  return Object.assign(async (input, init) => {
158
185
  const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
159
186
  // Collection/auth control traffic cannot generate a feedback loop.
160
- if (url.pathname.startsWith('/internal/activity') ||
161
- url.pathname.startsWith('/auth/service-token') ||
162
- url.pathname === '/platform-infrastructure')
187
+ if (!isObservedPath(url.pathname, 'outbound'))
163
188
  return fetcher(input, init);
164
189
  const peer = resolveEndpoint(url);
165
190
  const flow = {
@@ -175,7 +200,7 @@ function instrumentTrafficFetch(fetcher, collector, service, region, resolveEndp
175
200
  };
176
201
  const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
177
202
  if (peer)
178
- headers.set('X-Oxy-Source-Region', region);
203
+ headers.set(index_js_1.OXY_SOURCE_REGION_HEADER, region);
179
204
  let response;
180
205
  try {
181
206
  response = await fetcher(input, { ...init, headers });
@@ -185,7 +210,7 @@ function instrumentTrafficFetch(fetcher, collector, service, region, resolveEndp
185
210
  throw error;
186
211
  }
187
212
  const targetRegion = peer
188
- ? normalizeInfrastructureRegion(response.headers.get('X-Oxy-Region') ?? undefined)
213
+ ? normalizeInfrastructureRegion(response.headers.get(index_js_1.OXY_REGION_HEADER) ?? undefined)
189
214
  : undefined;
190
215
  collector.record({ ...flow, targetRegion });
191
216
  collector.record({
@@ -203,11 +228,12 @@ function instrumentTrafficFetch(fetcher, collector, service, region, resolveEndp
203
228
  // to be an internal service. Region is infrastructure metadata, never IP geo.
204
229
  const OXY_API_SERVICES = {
205
230
  'api.oxy.so': 'oxy-api',
206
- 'api.website.oxy.so': 'website',
207
231
  'website-api.oxy.so': 'website',
208
232
  'api.mention.earth': 'mention',
209
233
  'api.mercaria.co': 'mercaria',
234
+ // Old Alia app builds still call it; goes when api.alia.onl is retired (Gemies ADR 0034).
210
235
  'api.alia.onl': 'alia',
236
+ 'api.gemies.ai': 'gemies',
211
237
  'kaana.ai': 'kaana',
212
238
  'api.homiio.com': 'homiio',
213
239
  'api.syra.fm': 'syra',
package/dist/cjs/index.js CHANGED
@@ -1,5 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.OXY_EDGE_REGION_HEADER = exports.OXY_ACTIVITY_ID_HEADER = void 0;
3
+ exports.OXY_TELEMETRY_EXPOSED_HEADERS = exports.OXY_TELEMETRY_REQUEST_HEADERS = exports.OXY_SOURCE_REGION_HEADER = exports.OXY_REGION_HEADER = exports.OXY_EDGE_REGION_HEADER = exports.OXY_ACTIVITY_ID_HEADER = void 0;
4
+ /** Request header a browser sends: the ephemeral anonymous activity ID. */
4
5
  exports.OXY_ACTIVITY_ID_HEADER = 'X-Oxy-Activity-Id';
6
+ /** Request header a browser sends: the three-letter Cloudflare PoP code. */
5
7
  exports.OXY_EDGE_REGION_HEADER = 'X-Oxy-Edge-Region';
8
+ /** Response header a reporting service sets: the region that processed the request. */
9
+ exports.OXY_REGION_HEADER = 'X-Oxy-Region';
10
+ /** Request header one Oxy service sends another: the caller's infrastructure region. */
11
+ exports.OXY_SOURCE_REGION_HEADER = 'X-Oxy-Source-Region';
12
+ /**
13
+ * Request headers a browser may send and a CORS allow-list must therefore
14
+ * permit (`Access-Control-Allow-Headers`). `X-Oxy-Source-Region` is service to
15
+ * service only and is deliberately absent. The list is derived from the
16
+ * constants above, so a CORS list built from it cannot drift from them.
17
+ */
18
+ exports.OXY_TELEMETRY_REQUEST_HEADERS = Object.freeze([
19
+ exports.OXY_ACTIVITY_ID_HEADER,
20
+ exports.OXY_EDGE_REGION_HEADER,
21
+ ]);
22
+ /**
23
+ * Response headers a browser client may need to read
24
+ * (`Access-Control-Expose-Headers`).
25
+ */
26
+ exports.OXY_TELEMETRY_EXPOSED_HEADERS = Object.freeze([exports.OXY_REGION_HEADER]);
@@ -27,6 +27,7 @@
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.HTTP_RATE_LIMITED = exports.HTTP_REQUEST_LATENCY = void 0;
29
29
  exports.statusClass = statusClass;
30
+ exports.createEmfPublisherIfDeployed = createEmfPublisherIfDeployed;
30
31
  exports.createEmfPublisher = createEmfPublisher;
31
32
  /** One HTTP request: SampleCount is the request count, percentiles the latency. */
32
33
  exports.HTTP_REQUEST_LATENCY = {
@@ -102,6 +103,15 @@ function seriesKey(metric, labels) {
102
103
  function defaultWrite(line) {
103
104
  globalThis.process?.stdout?.write?.(`${line}\n`);
104
105
  }
106
+ /** Set by ECS on every task: the one reliable signal that `awslogs` is capturing stdout. */
107
+ const ECS_METADATA_ENV = 'ECS_CONTAINER_METADATA_URI_V4';
108
+ /**
109
+ * `createEmfPublisher` on ECS, `null` everywhere else (a laptop, CI, a test):
110
+ * the gate oxy-api and Mention each repeated. `env` is injectable for tests.
111
+ */
112
+ function createEmfPublisherIfDeployed(options, env = globalThis.process?.env ?? {}) {
113
+ return env[ECS_METADATA_ENV] ? createEmfPublisher(options) : null;
114
+ }
105
115
  function createEmfPublisher(options) {
106
116
  const flushIntervalMs = options.flushIntervalMs ?? 60000;
107
117
  const maxSeries = options.maxSeries ?? 2000;
@@ -3,12 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_ACTIVE_CLIENT_WINDOW_MS = exports.DEFAULT_PRESENCE_BUCKET_MS = void 0;
4
4
  exports.normalizeEdgePop = normalizeEdgePop;
5
5
  exports.normalizeActivityId = normalizeActivityId;
6
+ exports.sourceRegionFromEdgePop = sourceRegionFromEdgePop;
6
7
  exports.metadataFromHeaders = metadataFromHeaders;
7
8
  exports.serviceFromPath = serviceFromPath;
8
9
  exports.activityFlow = activityFlow;
9
10
  exports.presenceBucket = presenceBucket;
10
11
  exports.presenceKeys = presenceKeys;
12
+ const index_js_1 = require("./index.js");
11
13
  exports.DEFAULT_PRESENCE_BUCKET_MS = 30000;
14
+ /** @deprecated Unused by any Oxy repo; `presenceKeys` already encodes the window. Removal is planned for the next major. */
12
15
  exports.DEFAULT_ACTIVE_CLIENT_WINDOW_MS = 60000;
13
16
  function firstHeaderValue(value) {
14
17
  if (typeof value === 'string')
@@ -25,23 +28,43 @@ function normalizeActivityId(value) {
25
28
  const candidate = firstHeaderValue(value);
26
29
  return candidate && /^[a-z0-9_-]{16,64}$/i.test(candidate) ? candidate : undefined;
27
30
  }
31
+ /**
32
+ * The collector's source region for a normalised edge PoP (`edge-<pop>`), the
33
+ * one derivation every reporter shares. `undefined` when there is no PoP.
34
+ */
35
+ function sourceRegionFromEdgePop(edgePop) {
36
+ return edgePop ? `edge-${edgePop}` : undefined;
37
+ }
28
38
  function metadataFromHeaders(headers) {
29
- const forwardedPop = normalizeEdgePop(headers['x-oxy-edge-region']);
39
+ const forwardedPop = normalizeEdgePop(headers[index_js_1.OXY_EDGE_REGION_HEADER.toLowerCase()]);
30
40
  const ray = firstHeaderValue(headers['cf-ray']);
31
41
  const rayPop = ray?.match(/-([a-z]{3})$/i)?.[1]?.toLowerCase();
32
- const activityId = normalizeActivityId(headers['x-oxy-activity-id']);
42
+ const activityId = normalizeActivityId(headers[index_js_1.OXY_ACTIVITY_ID_HEADER.toLowerCase()]);
33
43
  return {
34
44
  ...(activityId ? { activityId } : {}),
35
45
  ...(forwardedPop || rayPop ? { edgePop: forwardedPop ?? rayPop } : {}),
36
46
  };
37
47
  }
48
+ /**
49
+ * @deprecated Unused by any Oxy repo. For the bounded activity category of a
50
+ * path use `trafficType` from `@oxy.so/telemetry/collector`. Removal is planned
51
+ * for the next major.
52
+ */
38
53
  function serviceFromPath(path) {
39
54
  const segment = path.split('/').filter(Boolean)[0];
40
55
  return segment && /^[a-z][a-z0-9-]{0,31}$/i.test(segment) ? segment.toLowerCase() : 'platform';
41
56
  }
57
+ /**
58
+ * @deprecated Unused by any Oxy repo; `createTrafficCollector` keys its own
59
+ * flows. Removal is planned for the next major.
60
+ */
42
61
  function activityFlow(sourceRegion, service) {
43
62
  return { sourceRegion, service, key: `${sourceRegion ?? ''}|${service}` };
44
63
  }
64
+ /**
65
+ * @deprecated Unused outside `presenceKeys`; call `presenceKeys` for the
66
+ * current and previous bucket keys. Removal is planned for the next major.
67
+ */
45
68
  function presenceBucket(observedAt, bucketMs = exports.DEFAULT_PRESENCE_BUCKET_MS) {
46
69
  if (!Number.isFinite(observedAt) || !Number.isFinite(bucketMs) || bucketMs <= 0) {
47
70
  throw new RangeError('observedAt and bucketMs must define a finite positive bucket');
@@ -49,9 +49,7 @@ function observeTrafficSocket(socket, collector, service, region, options = {})
49
49
  const edgePop = (0, server_js_1.metadataFromHeaders)(socket.handshake.headers).edgePop;
50
50
  const peerRegion = peerService
51
51
  ? (0, collector_js_1.normalizeInfrastructureRegion)(peer?.region)
52
- : edgePop
53
- ? `edge-${edgePop}`
54
- : undefined;
52
+ : (0, server_js_1.sourceRegionFromEdgePop)(edgePop);
55
53
  collector.record({
56
54
  region,
57
55
  service,
@@ -90,9 +88,7 @@ function observeTrafficWebSocket(socket, collector, service, region, options = {
90
88
  const edgePop = (0, server_js_1.metadataFromHeaders)(options.headers ?? {}).edgePop;
91
89
  const peerRegion = peerService
92
90
  ? (0, collector_js_1.normalizeInfrastructureRegion)(peer?.region)
93
- : edgePop
94
- ? `edge-${edgePop}`
95
- : undefined;
91
+ : (0, server_js_1.sourceRegionFromEdgePop)(edgePop);
96
92
  collector.record({
97
93
  service,
98
94
  region,
@@ -26,6 +26,12 @@ function eligibleLocation(location) {
26
26
  location.hostname !== '127.0.0.1' &&
27
27
  location.hostname !== '[::1]');
28
28
  }
29
+ /**
30
+ * @deprecated No Oxy repo constructs its own runtime: use the module-level
31
+ * `getBrowserActivityIdHeader`, `getBrowserEdgeRegionHeader` and
32
+ * `peekBrowserEdgeRegionHeader`. The factory remains the test seam. Removal from
33
+ * the public surface is planned for the next major.
34
+ */
29
35
  export function createBrowserTelemetry(options = {}) {
30
36
  let activityId = null;
31
37
  let activityIdCreatedAt = 0;
@@ -118,4 +124,9 @@ const defaultBrowserTelemetry = createBrowserTelemetry();
118
124
  export const getBrowserActivityIdHeader = () => defaultBrowserTelemetry.getActivityIdHeader();
119
125
  export const getBrowserEdgeRegionHeader = () => defaultBrowserTelemetry.getEdgeRegionHeader();
120
126
  export const peekBrowserEdgeRegionHeader = () => defaultBrowserTelemetry.peekEdgeRegionHeader();
127
+ /**
128
+ * @deprecated Unused by any Oxy repo. `@oxy.so/core` sends
129
+ * `peekBrowserEdgeRegionHeader()` and `getBrowserActivityIdHeader()` per request.
130
+ * Removal is planned for the next major.
131
+ */
121
132
  export const getBrowserTelemetryHeaders = () => defaultBrowserTelemetry.getHeaders();
@@ -1,4 +1,5 @@
1
- import { metadataFromHeaders } from './server.js';
1
+ import { OXY_REGION_HEADER, OXY_SOURCE_REGION_HEADER, } from './index.js';
2
+ import { metadataFromHeaders, sourceRegionFromEdgePop } from './server.js';
2
3
  const TYPES = {
3
4
  auth: 'identity',
4
5
  session: 'identity',
@@ -39,6 +40,35 @@ export function trafficType(path) {
39
40
  parts.shift();
40
41
  return TYPES[parts[0] ?? ''] ?? 'platform';
41
42
  }
43
+ /**
44
+ * Every path a reporter does not observe, in ONE table. Each entry says which
45
+ * directions it applies to: the inbound and outbound sets differ today (an
46
+ * outbound call to a probe or to `/platform-stats` IS recorded), and that is
47
+ * preserved exactly. Unifying them is a separate, deliberate decision.
48
+ */
49
+ export const UNOBSERVED_PATHS = [
50
+ {
51
+ directions: 'inbound',
52
+ matches: (path) => /^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|infra-status)(?:\/|$)/.test(path),
53
+ },
54
+ {
55
+ directions: 'inbound',
56
+ matches: (path) => /^\/(?:api\/)?platform-infrastructure(?:\/|$)/.test(path),
57
+ },
58
+ { directions: 'outbound', matches: (path) => path === '/platform-infrastructure' },
59
+ { directions: 'both', matches: (path) => path.startsWith('/internal/activity') },
60
+ { directions: 'both', matches: (path) => path.startsWith('/auth/service-token') },
61
+ { directions: 'inbound', matches: (path) => path.startsWith('/cdn-cgi/') },
62
+ ];
63
+ /**
64
+ * Is this path part of what a reporter observes in this direction? Collection
65
+ * itself, service-token minting and (inbound) probes and the dashboard
66
+ * transport are control traffic: observing them would feed back into the very
67
+ * activity they carry.
68
+ */
69
+ export function isObservedPath(path, direction) {
70
+ return !UNOBSERVED_PATHS.some((entry) => (entry.directions === 'both' || entry.directions === direction) && entry.matches(path));
71
+ }
42
72
  export function createTrafficCollector(publish, now = Date.now) {
43
73
  const pending = new Map();
44
74
  let flushing = null;
@@ -79,13 +109,10 @@ export function createTrafficCollector(publish, now = Date.now) {
79
109
  }
80
110
  export function trafficMiddleware(collector, service, region) {
81
111
  return (request, response, next) => {
82
- response.setHeader?.('X-Oxy-Region', region);
112
+ response.setHeader?.(OXY_REGION_HEADER, region);
83
113
  const path = request.path ?? request.url ?? '/';
84
114
  // Never report collection itself, probes or the dashboard transport.
85
- if (/^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|platform-infrastructure|infra-status)(?:\/|$)/.test(path) ||
86
- path.startsWith('/internal/activity') ||
87
- path.startsWith('/auth/service-token') ||
88
- path.startsWith('/cdn-cgi/')) {
115
+ if (!isObservedPath(path, 'inbound')) {
89
116
  next();
90
117
  return;
91
118
  }
@@ -99,12 +126,10 @@ export function trafficMiddleware(collector, service, region) {
99
126
  .replace(/[^a-z0-9-]/g, '-')
100
127
  .slice(0, 40);
101
128
  const internal = Boolean(name);
102
- const forwardedRegion = request.headers['x-oxy-source-region'];
129
+ const forwardedRegion = request.headers[OXY_SOURCE_REGION_HEADER.toLowerCase()];
103
130
  const peerRegion = internal
104
131
  ? normalizeInfrastructureRegion(forwardedRegion)
105
- : edgePop
106
- ? `edge-${edgePop}`
107
- : undefined;
132
+ : sourceRegionFromEdgePop(edgePop);
108
133
  return {
109
134
  region,
110
135
  service,
@@ -148,9 +173,7 @@ export function instrumentTrafficFetch(fetcher, collector, service, region, reso
148
173
  return Object.assign(async (input, init) => {
149
174
  const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
150
175
  // Collection/auth control traffic cannot generate a feedback loop.
151
- if (url.pathname.startsWith('/internal/activity') ||
152
- url.pathname.startsWith('/auth/service-token') ||
153
- url.pathname === '/platform-infrastructure')
176
+ if (!isObservedPath(url.pathname, 'outbound'))
154
177
  return fetcher(input, init);
155
178
  const peer = resolveEndpoint(url);
156
179
  const flow = {
@@ -166,7 +189,7 @@ export function instrumentTrafficFetch(fetcher, collector, service, region, reso
166
189
  };
167
190
  const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
168
191
  if (peer)
169
- headers.set('X-Oxy-Source-Region', region);
192
+ headers.set(OXY_SOURCE_REGION_HEADER, region);
170
193
  let response;
171
194
  try {
172
195
  response = await fetcher(input, { ...init, headers });
@@ -176,7 +199,7 @@ export function instrumentTrafficFetch(fetcher, collector, service, region, reso
176
199
  throw error;
177
200
  }
178
201
  const targetRegion = peer
179
- ? normalizeInfrastructureRegion(response.headers.get('X-Oxy-Region') ?? undefined)
202
+ ? normalizeInfrastructureRegion(response.headers.get(OXY_REGION_HEADER) ?? undefined)
180
203
  : undefined;
181
204
  collector.record({ ...flow, targetRegion });
182
205
  collector.record({
@@ -194,11 +217,12 @@ export function instrumentTrafficFetch(fetcher, collector, service, region, reso
194
217
  // to be an internal service. Region is infrastructure metadata, never IP geo.
195
218
  const OXY_API_SERVICES = {
196
219
  'api.oxy.so': 'oxy-api',
197
- 'api.website.oxy.so': 'website',
198
220
  'website-api.oxy.so': 'website',
199
221
  'api.mention.earth': 'mention',
200
222
  'api.mercaria.co': 'mercaria',
223
+ // Old Alia app builds still call it; goes when api.alia.onl is retired (Gemies ADR 0034).
201
224
  'api.alia.onl': 'alia',
225
+ 'api.gemies.ai': 'gemies',
202
226
  'kaana.ai': 'kaana',
203
227
  'api.homiio.com': 'homiio',
204
228
  'api.syra.fm': 'syra',
package/dist/esm/index.js CHANGED
@@ -1,2 +1,23 @@
1
+ /** Request header a browser sends: the ephemeral anonymous activity ID. */
1
2
  export const OXY_ACTIVITY_ID_HEADER = 'X-Oxy-Activity-Id';
3
+ /** Request header a browser sends: the three-letter Cloudflare PoP code. */
2
4
  export const OXY_EDGE_REGION_HEADER = 'X-Oxy-Edge-Region';
5
+ /** Response header a reporting service sets: the region that processed the request. */
6
+ export const OXY_REGION_HEADER = 'X-Oxy-Region';
7
+ /** Request header one Oxy service sends another: the caller's infrastructure region. */
8
+ export const OXY_SOURCE_REGION_HEADER = 'X-Oxy-Source-Region';
9
+ /**
10
+ * Request headers a browser may send and a CORS allow-list must therefore
11
+ * permit (`Access-Control-Allow-Headers`). `X-Oxy-Source-Region` is service to
12
+ * service only and is deliberately absent. The list is derived from the
13
+ * constants above, so a CORS list built from it cannot drift from them.
14
+ */
15
+ export const OXY_TELEMETRY_REQUEST_HEADERS = Object.freeze([
16
+ OXY_ACTIVITY_ID_HEADER,
17
+ OXY_EDGE_REGION_HEADER,
18
+ ]);
19
+ /**
20
+ * Response headers a browser client may need to read
21
+ * (`Access-Control-Expose-Headers`).
22
+ */
23
+ export const OXY_TELEMETRY_EXPOSED_HEADERS = Object.freeze([OXY_REGION_HEADER]);
@@ -97,6 +97,15 @@ function seriesKey(metric, labels) {
97
97
  function defaultWrite(line) {
98
98
  globalThis.process?.stdout?.write?.(`${line}\n`);
99
99
  }
100
+ /** Set by ECS on every task: the one reliable signal that `awslogs` is capturing stdout. */
101
+ const ECS_METADATA_ENV = 'ECS_CONTAINER_METADATA_URI_V4';
102
+ /**
103
+ * `createEmfPublisher` on ECS, `null` everywhere else (a laptop, CI, a test):
104
+ * the gate oxy-api and Mention each repeated. `env` is injectable for tests.
105
+ */
106
+ export function createEmfPublisherIfDeployed(options, env = globalThis.process?.env ?? {}) {
107
+ return env[ECS_METADATA_ENV] ? createEmfPublisher(options) : null;
108
+ }
100
109
  export function createEmfPublisher(options) {
101
110
  const flushIntervalMs = options.flushIntervalMs ?? 60000;
102
111
  const maxSeries = options.maxSeries ?? 2000;