@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.
package/README.md CHANGED
@@ -6,11 +6,13 @@ 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
13
14
  keys/interfaces.
15
+ - `@oxy.so/telemetry/metrics` — operational metrics as CloudWatch EMF lines.
14
16
 
15
17
  Browser code must import the browser entry point explicitly. Server code must
16
18
  import the server entry point explicitly; the root entry point has no platform
@@ -19,12 +21,19 @@ side effects.
19
21
  ## Browser metadata
20
22
 
21
23
  ```ts
22
- import { getBrowserTelemetryHeaders } from '@oxy.so/telemetry/browser';
24
+ import { getBrowserActivityIdHeader, peekBrowserEdgeRegionHeader } from '@oxy.so/telemetry/browser';
23
25
 
24
- const headers = await getBrowserTelemetryHeaders();
26
+ const headers = { ...peekBrowserEdgeRegionHeader(), ...getBrowserActivityIdHeader() };
25
27
  await fetch('https://api.oxy.so/session/status', { headers });
26
28
  ```
27
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
+
28
37
  `X-Oxy-Activity-Id` is generated in memory for the current page runtime and
29
38
  rotates every five minutes. It is not placed in a cookie, local storage,
30
39
  session storage, IndexedDB or a URL. Closing or reloading the page loses it.
@@ -34,34 +43,32 @@ from the same-origin `/cdn-cgi/trace` endpoint. The helper reads no IP field and
34
43
  returns, logs and persists none of the rest of the trace response. Discovery is
35
44
  cached for 15 seconds per runtime (including failed discovery), has a one-second timeout and is disabled on loopback.
36
45
 
37
- Use `createBrowserTelemetry()` to create an isolated runtime or inject clock,
38
- 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.
39
48
 
40
49
  ## Server primitives
41
50
 
42
51
  ```ts
43
52
  import {
44
- activityFlow,
45
53
  metadataFromHeaders,
46
54
  presenceKeys,
47
- serviceFromPath,
48
- type ActivityCardinalityAdapter,
55
+ sourceRegionFromEdgePop,
49
56
  } from '@oxy.so/telemetry/server';
50
57
 
51
58
  const metadata = metadataFromHeaders(request.headers);
52
- const sourceRegion = metadata.edgePop ? `edge-${metadata.edgePop}` : undefined;
53
- const flow = activityFlow(sourceRegion, serviceFromPath(request.path));
59
+ const sourceRegion = sourceRegionFromEdgePop(metadata.edgePop); // 'edge-mad' | undefined
54
60
  const keys = sourceRegion ? presenceKeys(sourceRegion, Date.now()) : [];
55
61
  ```
56
62
 
57
63
  Validation accepts a three-letter PoP and a 16–64 character opaque activity ID.
58
- It ignores IP and forwarding headers. `serviceFromPath` keeps only a bounded,
59
- static first route segment, so IDs and full paths cannot enter aggregates.
60
-
61
- `ActivityCardinalityAdapter` describes the minimal storage boundary for an
62
- implementation such as Redis HyperLogLog. This package does not instantiate a
64
+ It ignores IP and forwarding headers. This package does not instantiate a
63
65
  store, keep server-global maps, start timers or send realtime events.
64
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
+
65
72
  ## Active-client definition
66
73
 
67
74
  An active client is one distinct ephemeral activity ID observed for an edge PoP
@@ -105,8 +112,10 @@ follow the actual movement, even when two services share one region.
105
112
  identity after route authentication. `instrumentTrafficFetch` observes actual
106
113
  outbound calls; internal destination regions come from the peer's `X-Oxy-Region`
107
114
  response, not a guessed IP location. Unlocated peers remain unlocated. Health,
108
- telemetry collection, infrastructure snapshots and service-token minting are
109
- 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.
110
119
  Custom transports can call the collector's `record` method explicitly.
111
120
 
112
121
  Each collector retains at most 512 distinct pending flows and publishes chunks
@@ -115,7 +124,8 @@ durable retry queue. These are live operational observations, not billing or
115
124
  lossless historical request totals. No dashboard viewer is needed to collect.
116
125
 
117
126
  Realtime transport publishes aggregate buckets, not individual metadata. A
118
- 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
119
129
  clients, source/target regions, service and window timestamps. Transport and
120
130
  fan-out are application concerns and intentionally absent from this package.
121
131
 
@@ -130,11 +140,65 @@ installed in each service before this can represent the whole ecosystem.
130
140
  The authenticated publisher is `createEcosystemTraffic` from `@oxy.so/core/server`.
131
141
  It exposes HTTP middleware, `wrapFetch`, `record` for explicit transport events,
132
142
  and `stop` for graceful removal. It requires a known infrastructure region (or
133
- explicit infrastructure coordinates) and one of three ways to authenticate: an
134
- `OXY_ACTIVITY_API_KEY`/`OXY_ACTIVITY_API_SECRET` pair, an
135
- `OXY_SERVICE_API_KEY`/`OXY_SERVICE_API_SECRET` pair, or no credential at all
136
- where the process can attest its workload identity (ADR 0026). Credentials, user
137
- 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.
152
+
153
+ ## Operational metrics (CloudWatch EMF)
154
+
155
+ ```ts
156
+ import { createEmfPublisher, HTTP_REQUEST_LATENCY, statusClass } from '@oxy.so/telemetry/metrics';
157
+
158
+ const metrics = createEmfPublisher({ namespace: 'Oxy/Api', service: 'oxy-api' });
159
+ metrics.record(HTTP_REQUEST_LATENCY, elapsedMs, {
160
+ Route: '/users/:id', // a log property: free, grouped in Logs Insights
161
+ StatusClass: statusClass(res.statusCode), // a dimension: billed per value
162
+ Caller: 'anonymous',
163
+ });
164
+ // On shutdown:
165
+ metrics.stop();
166
+ ```
167
+
168
+ Observations are aggregated in memory and written once per interval (60 s) as
169
+ one stdout line per (metric, label set). On ECS with the `awslogs` driver,
170
+ CloudWatch Logs extracts the metrics itself: no agent, sidecar or IAM grant.
171
+ A `distribution` keeps a ~5 % bucketed histogram, so CloudWatch answers
172
+ SampleCount (the count) and percentiles for it.
173
+
174
+ Every distinct combination of DIMENSION values is a billed custom metric, so a
175
+ label is a dimension only when the definition lists it in `dimensionSets`, and
176
+ only closed, small sets belong there (status class, a first-party caller).
177
+ Route, method, table and every other label stay plain properties on the line:
178
+ `stats sum(RequestLatency.Count) by Route` in Logs Insights. At most
179
+ `maxSeries` (default 2000) series are held per interval; past that, new series
180
+ fold into one whose labels read `other`.
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.
138
202
 
139
203
  ## Development
140
204
 
@@ -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]);
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ /**
3
+ * Operational metrics for Oxy services, published as CloudWatch Embedded Metric
4
+ * Format (EMF) log lines.
5
+ *
6
+ * WHY EMF ON STDOUT: every Oxy backend runs on ECS with the `awslogs` driver, and
7
+ * CloudWatch Logs extracts metrics from any log event shaped as EMF — no agent,
8
+ * no sidecar, no scraper, no IAM grant beyond the logging the task already has.
9
+ * A registry that only an HTTP scrape can read (Prometheus) measures into a void
10
+ * when nothing scrapes it, which is how Mention's counters lost every value on
11
+ * every deploy.
12
+ *
13
+ * WHY AGGREGATED: values are folded in memory and flushed once per interval as
14
+ * ONE line per (metric, label set), not one line per observation. A latency is
15
+ * kept as a distribution (`Values`/`Counts`), so CloudWatch still answers
16
+ * SampleCount, p50 and p99 for it — the request COUNT is the distribution's
17
+ * SampleCount, so a service needs no separate counter for it.
18
+ *
19
+ * WHY MOST LABELS ARE NOT DIMENSIONS: every distinct combination of dimension
20
+ * values is a billed custom metric. Route x method as dimensions once measured
21
+ * 405 metrics and ~80 % of the account's CloudWatch bill (Mention #1334). So a
22
+ * label becomes a DIMENSION only when a metric definition names it in
23
+ * `dimensionSets`, and only labels with a small, closed value set belong there.
24
+ * Every other label is written as a plain property of the line: free to store,
25
+ * and grouped in Logs Insights (`stats sum(RequestLatency.Count) by Route`).
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.HTTP_RATE_LIMITED = exports.HTTP_REQUEST_LATENCY = void 0;
29
+ exports.statusClass = statusClass;
30
+ exports.createEmfPublisherIfDeployed = createEmfPublisherIfDeployed;
31
+ exports.createEmfPublisher = createEmfPublisher;
32
+ /** One HTTP request: SampleCount is the request count, percentiles the latency. */
33
+ exports.HTTP_REQUEST_LATENCY = {
34
+ name: 'RequestLatency',
35
+ unit: 'Milliseconds',
36
+ kind: 'distribution',
37
+ dimensionSets: [['StatusClass'], ['Caller']],
38
+ };
39
+ /** One request a rate limiter refused (429), per calling application. */
40
+ exports.HTTP_RATE_LIMITED = {
41
+ name: 'RateLimited',
42
+ unit: 'Count',
43
+ kind: 'sum',
44
+ dimensionSets: [['Caller']],
45
+ };
46
+ /** `2xx`, `4xx`, … — a closed set, safe as a dimension. */
47
+ function statusClass(status) {
48
+ return Number.isInteger(status) && status >= 100 && status <= 599
49
+ ? `${Math.floor(status / 100)}xx`
50
+ : 'other';
51
+ }
52
+ /** EMF allows at most 100 distinct values per metric on one line. */
53
+ const MAX_DISTRIBUTION_VALUES = 100;
54
+ /** Neighbouring values within ~5 % share a bucket, which bounds the set. */
55
+ const BUCKET_BASE = 1.05;
56
+ const LABEL_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
57
+ const MAX_LABEL_VALUE_LENGTH = 128;
58
+ const RESERVED = new Set(['_aws', 'Service']);
59
+ /** Representative value of a bucket: values within ~5 % collapse together. */
60
+ function bucketOf(value) {
61
+ if (value <= 0)
62
+ return 0;
63
+ const exponent = Math.round(Math.log(value) / Math.log(BUCKET_BASE));
64
+ return Number((BUCKET_BASE ** exponent).toPrecision(3));
65
+ }
66
+ /** Merge adjacent buckets (keeping the larger value) until EMF accepts them. */
67
+ function boundedDistribution(buckets) {
68
+ let entries = [...buckets.entries()].sort(([left], [right]) => left - right);
69
+ while (entries.length > MAX_DISTRIBUTION_VALUES) {
70
+ const merged = [];
71
+ for (let index = 0; index < entries.length; index += 2) {
72
+ const [value, count] = entries[index];
73
+ const next = entries[index + 1];
74
+ merged.push(next ? [next[0], count + next[1]] : [value, count]);
75
+ }
76
+ entries = merged;
77
+ }
78
+ return { values: entries.map(([value]) => value), counts: entries.map(([, count]) => count) };
79
+ }
80
+ function cleanLabels(metric, labels) {
81
+ const clean = {};
82
+ for (const [name, raw] of Object.entries(labels ?? {})) {
83
+ if (!LABEL_NAME.test(name) || RESERVED.has(name) || name === metric.name)
84
+ continue;
85
+ const value = String(raw).slice(0, MAX_LABEL_VALUE_LENGTH);
86
+ clean[name] = value.length > 0 ? value : 'unknown';
87
+ }
88
+ // A dimension named by the definition must be present on every line, or
89
+ // CloudWatch drops the metric from that line.
90
+ for (const set of metric.dimensionSets ?? []) {
91
+ for (const name of set)
92
+ clean[name] ?? (clean[name] = 'unknown');
93
+ }
94
+ return clean;
95
+ }
96
+ function seriesKey(metric, labels) {
97
+ const pairs = Object.keys(labels)
98
+ .sort()
99
+ .map((name) => `${name}=${labels[name]}`);
100
+ return `${metric.name}\u0000${pairs.join('\u0000')}`;
101
+ }
102
+ /** One line on stdout. Reached through `globalThis`: this package carries no Node types. */
103
+ function defaultWrite(line) {
104
+ globalThis.process?.stdout?.write?.(`${line}\n`);
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
+ }
115
+ function createEmfPublisher(options) {
116
+ const flushIntervalMs = options.flushIntervalMs ?? 60000;
117
+ const maxSeries = options.maxSeries ?? 2000;
118
+ const write = options.write ?? defaultWrite;
119
+ const now = options.now ?? Date.now;
120
+ let series = new Map();
121
+ const line = (entry, timestamp) => {
122
+ const { metric } = entry;
123
+ const dimensions = [
124
+ ['Service'],
125
+ ...(metric.dimensionSets ?? []).map((set) => ['Service', ...set]),
126
+ ];
127
+ let value;
128
+ if (metric.kind === 'sum')
129
+ value = entry.sum;
130
+ else if (metric.kind === 'gauge')
131
+ value = entry.last;
132
+ else {
133
+ const { values, counts } = boundedDistribution(entry.buckets);
134
+ value = {
135
+ Values: values,
136
+ Counts: counts,
137
+ Max: entry.max,
138
+ Min: entry.min,
139
+ Count: entry.count,
140
+ Sum: entry.sum,
141
+ };
142
+ }
143
+ return JSON.stringify({
144
+ _aws: {
145
+ Timestamp: timestamp,
146
+ CloudWatchMetrics: [
147
+ {
148
+ Namespace: options.namespace,
149
+ Dimensions: dimensions,
150
+ Metrics: [{ Name: metric.name, Unit: metric.unit }],
151
+ },
152
+ ],
153
+ },
154
+ ...entry.labels,
155
+ Service: options.service,
156
+ [metric.name]: value,
157
+ });
158
+ };
159
+ const flush = () => {
160
+ if (series.size === 0)
161
+ return;
162
+ const current = series;
163
+ series = new Map();
164
+ const timestamp = now();
165
+ for (const entry of current.values()) {
166
+ try {
167
+ write(line(entry, timestamp));
168
+ }
169
+ catch {
170
+ // Metrics are best effort; a failed write must never reach a request.
171
+ }
172
+ }
173
+ };
174
+ const timer = setInterval(flush, flushIntervalMs);
175
+ timer.unref?.();
176
+ return {
177
+ record(metric, value, labels) {
178
+ if (!Number.isFinite(value))
179
+ return;
180
+ if (metric.kind !== 'gauge' && value < 0)
181
+ return;
182
+ let clean = cleanLabels(metric, labels);
183
+ let key = seriesKey(metric, clean);
184
+ if (!series.has(key) && series.size >= maxSeries) {
185
+ clean = Object.fromEntries(Object.keys(clean).map((name) => [name, 'other']));
186
+ key = seriesKey(metric, clean);
187
+ }
188
+ let entry = series.get(key);
189
+ if (!entry) {
190
+ entry = {
191
+ metric,
192
+ labels: clean,
193
+ sum: 0,
194
+ last: 0,
195
+ count: 0,
196
+ min: Number.POSITIVE_INFINITY,
197
+ max: Number.NEGATIVE_INFINITY,
198
+ buckets: new Map(),
199
+ };
200
+ series.set(key, entry);
201
+ }
202
+ entry.sum += value;
203
+ entry.last = value;
204
+ entry.count += 1;
205
+ entry.min = Math.min(entry.min, value);
206
+ entry.max = Math.max(entry.max, value);
207
+ if (metric.kind === 'distribution') {
208
+ const bucket = bucketOf(value);
209
+ entry.buckets.set(bucket, (entry.buckets.get(bucket) ?? 0) + 1);
210
+ }
211
+ },
212
+ flush,
213
+ stop() {
214
+ clearInterval(timer);
215
+ flush();
216
+ },
217
+ };
218
+ }
@@ -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,