@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 +87 -23
- package/dist/cjs/browser.js +11 -0
- package/dist/cjs/collector.js +41 -15
- package/dist/cjs/index.js +22 -1
- package/dist/cjs/metrics.js +218 -0
- package/dist/cjs/server.js +25 -2
- package/dist/cjs/socket.js +2 -6
- package/dist/esm/browser.js +11 -0
- package/dist/esm/collector.js +40 -16
- package/dist/esm/index.js +21 -0
- package/dist/esm/metrics.js +212 -0
- package/dist/esm/server.js +24 -2
- package/dist/esm/socket.js +3 -7
- package/dist/types/browser.d.ts +11 -0
- package/dist/types/collector.d.ts +21 -20
- package/dist/types/index.d.ts +57 -4
- package/dist/types/metrics.d.ts +84 -0
- package/dist/types/package.json +1 -0
- package/dist/types/server.d.ts +26 -1
- package/package.json +27 -13
- package/src/__tests__/collector.test.ts +17 -0
- package/src/__tests__/emfGolden.test.ts +53 -0
- package/src/__tests__/metrics.test.ts +45 -0
- package/src/__tests__/singleSources.test.ts +184 -0
- package/src/browser.ts +11 -0
- package/src/collector.ts +67 -42
- package/src/index.ts +65 -4
- package/src/metrics.ts +302 -0
- package/src/server.ts +36 -3
- package/src/socket.ts +3 -7
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
|
|
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 {
|
|
24
|
+
import { getBrowserActivityIdHeader, peekBrowserEdgeRegionHeader } from '@oxy.so/telemetry/browser';
|
|
23
25
|
|
|
24
|
-
const headers =
|
|
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
|
-
|
|
38
|
-
crypto, location and trace-fetch dependencies
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
109
|
-
excluded from observation
|
|
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
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
package/dist/cjs/browser.js
CHANGED
|
@@ -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;
|
package/dist/cjs/collector.js
CHANGED
|
@@ -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?.(
|
|
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 (
|
|
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[
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/dist/cjs/server.js
CHANGED
|
@@ -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[
|
|
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[
|
|
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');
|
package/dist/cjs/socket.js
CHANGED
|
@@ -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,
|