@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/src/collector.ts
CHANGED
|
@@ -1,24 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
OXY_REGION_HEADER,
|
|
3
|
+
OXY_SOURCE_REGION_HEADER,
|
|
4
|
+
type TrafficAggregate,
|
|
5
|
+
type TrafficFlow,
|
|
6
|
+
type TrafficType,
|
|
7
|
+
} from './index.js';
|
|
8
|
+
import { metadataFromHeaders, sourceRegionFromEdgePop, type TelemetryHeaders } from './server.js';
|
|
2
9
|
|
|
3
|
-
export type
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
sourceService?: string;
|
|
11
|
-
targetService?: string;
|
|
12
|
-
service: string;
|
|
13
|
-
scope: TrafficScope;
|
|
14
|
-
direction: TrafficDirection;
|
|
15
|
-
activityType: TrafficType;
|
|
16
|
-
}
|
|
17
|
-
export interface TrafficAggregate extends TrafficFlow {
|
|
18
|
-
requests: number;
|
|
19
|
-
windowStartedAt: string;
|
|
20
|
-
emittedAt: string;
|
|
21
|
-
}
|
|
10
|
+
export type {
|
|
11
|
+
TrafficAggregate,
|
|
12
|
+
TrafficDirection,
|
|
13
|
+
TrafficFlow,
|
|
14
|
+
TrafficScope,
|
|
15
|
+
TrafficType,
|
|
16
|
+
} from './index.js';
|
|
22
17
|
|
|
23
18
|
const TYPES: Record<string, TrafficType> = {
|
|
24
19
|
auth: 'identity',
|
|
@@ -58,6 +53,49 @@ export function trafficType(path: string): TrafficType {
|
|
|
58
53
|
return TYPES[parts[0] ?? ''] ?? 'platform';
|
|
59
54
|
}
|
|
60
55
|
|
|
56
|
+
/** Which side of a request a reporter is observing. */
|
|
57
|
+
export type ObservationDirection = 'inbound' | 'outbound';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every path a reporter does not observe, in ONE table. Each entry says which
|
|
61
|
+
* directions it applies to: the inbound and outbound sets differ today (an
|
|
62
|
+
* outbound call to a probe or to `/platform-stats` IS recorded), and that is
|
|
63
|
+
* preserved exactly. Unifying them is a separate, deliberate decision.
|
|
64
|
+
*/
|
|
65
|
+
export const UNOBSERVED_PATHS: readonly {
|
|
66
|
+
readonly directions: ObservationDirection | 'both';
|
|
67
|
+
readonly matches: (path: string) => boolean;
|
|
68
|
+
}[] = [
|
|
69
|
+
{
|
|
70
|
+
directions: 'inbound',
|
|
71
|
+
matches: (path) =>
|
|
72
|
+
/^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|infra-status)(?:\/|$)/.test(
|
|
73
|
+
path,
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
directions: 'inbound',
|
|
78
|
+
matches: (path) => /^\/(?:api\/)?platform-infrastructure(?:\/|$)/.test(path),
|
|
79
|
+
},
|
|
80
|
+
{ directions: 'outbound', matches: (path) => path === '/platform-infrastructure' },
|
|
81
|
+
{ directions: 'both', matches: (path) => path.startsWith('/internal/activity') },
|
|
82
|
+
{ directions: 'both', matches: (path) => path.startsWith('/auth/service-token') },
|
|
83
|
+
{ directions: 'inbound', matches: (path) => path.startsWith('/cdn-cgi/') },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Is this path part of what a reporter observes in this direction? Collection
|
|
88
|
+
* itself, service-token minting and (inbound) probes and the dashboard
|
|
89
|
+
* transport are control traffic: observing them would feed back into the very
|
|
90
|
+
* activity they carry.
|
|
91
|
+
*/
|
|
92
|
+
export function isObservedPath(path: string, direction: ObservationDirection): boolean {
|
|
93
|
+
return !UNOBSERVED_PATHS.some(
|
|
94
|
+
(entry) =>
|
|
95
|
+
(entry.directions === 'both' || entry.directions === direction) && entry.matches(path),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
61
99
|
export function createTrafficCollector(
|
|
62
100
|
publish: (events: TrafficAggregate[]) => Promise<unknown>,
|
|
63
101
|
now = Date.now,
|
|
@@ -112,17 +150,10 @@ export function trafficMiddleware(
|
|
|
112
150
|
region: string,
|
|
113
151
|
) {
|
|
114
152
|
return (request: HttpRequest, response: HttpResponse, next: () => void): void => {
|
|
115
|
-
response.setHeader?.(
|
|
153
|
+
response.setHeader?.(OXY_REGION_HEADER, region);
|
|
116
154
|
const path = request.path ?? request.url ?? '/';
|
|
117
155
|
// Never report collection itself, probes or the dashboard transport.
|
|
118
|
-
if (
|
|
119
|
-
/^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|platform-infrastructure|infra-status)(?:\/|$)/.test(
|
|
120
|
-
path,
|
|
121
|
-
) ||
|
|
122
|
-
path.startsWith('/internal/activity') ||
|
|
123
|
-
path.startsWith('/auth/service-token') ||
|
|
124
|
-
path.startsWith('/cdn-cgi/')
|
|
125
|
-
) {
|
|
156
|
+
if (!isObservedPath(path, 'inbound')) {
|
|
126
157
|
next();
|
|
127
158
|
return;
|
|
128
159
|
}
|
|
@@ -136,12 +167,10 @@ export function trafficMiddleware(
|
|
|
136
167
|
.replace(/[^a-z0-9-]/g, '-')
|
|
137
168
|
.slice(0, 40);
|
|
138
169
|
const internal = Boolean(name);
|
|
139
|
-
const forwardedRegion = request.headers[
|
|
170
|
+
const forwardedRegion = request.headers[OXY_SOURCE_REGION_HEADER.toLowerCase()];
|
|
140
171
|
const peerRegion = internal
|
|
141
172
|
? normalizeInfrastructureRegion(forwardedRegion)
|
|
142
|
-
: edgePop
|
|
143
|
-
? `edge-${edgePop}`
|
|
144
|
-
: undefined;
|
|
173
|
+
: sourceRegionFromEdgePop(edgePop);
|
|
145
174
|
return {
|
|
146
175
|
region,
|
|
147
176
|
service,
|
|
@@ -196,12 +225,7 @@ export function instrumentTrafficFetch(
|
|
|
196
225
|
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url,
|
|
197
226
|
);
|
|
198
227
|
// Collection/auth control traffic cannot generate a feedback loop.
|
|
199
|
-
if (
|
|
200
|
-
url.pathname.startsWith('/internal/activity') ||
|
|
201
|
-
url.pathname.startsWith('/auth/service-token') ||
|
|
202
|
-
url.pathname === '/platform-infrastructure'
|
|
203
|
-
)
|
|
204
|
-
return fetcher(input, init);
|
|
228
|
+
if (!isObservedPath(url.pathname, 'outbound')) return fetcher(input, init);
|
|
205
229
|
const peer = resolveEndpoint(url);
|
|
206
230
|
const flow: TrafficFlow = {
|
|
207
231
|
service,
|
|
@@ -217,7 +241,7 @@ export function instrumentTrafficFetch(
|
|
|
217
241
|
const headers = new Headers(
|
|
218
242
|
init?.headers ?? (input instanceof Request ? input.headers : undefined),
|
|
219
243
|
);
|
|
220
|
-
if (peer) headers.set(
|
|
244
|
+
if (peer) headers.set(OXY_SOURCE_REGION_HEADER, region);
|
|
221
245
|
let response: Response;
|
|
222
246
|
try {
|
|
223
247
|
response = await fetcher(input, { ...init, headers });
|
|
@@ -226,7 +250,7 @@ export function instrumentTrafficFetch(
|
|
|
226
250
|
throw error;
|
|
227
251
|
}
|
|
228
252
|
const targetRegion = peer
|
|
229
|
-
? normalizeInfrastructureRegion(response.headers.get(
|
|
253
|
+
? normalizeInfrastructureRegion(response.headers.get(OXY_REGION_HEADER) ?? undefined)
|
|
230
254
|
: undefined;
|
|
231
255
|
collector.record({ ...flow, targetRegion });
|
|
232
256
|
collector.record({
|
|
@@ -245,11 +269,12 @@ export function instrumentTrafficFetch(
|
|
|
245
269
|
// to be an internal service. Region is infrastructure metadata, never IP geo.
|
|
246
270
|
const OXY_API_SERVICES: Record<string, string> = {
|
|
247
271
|
'api.oxy.so': 'oxy-api',
|
|
248
|
-
'api.website.oxy.so': 'website',
|
|
249
272
|
'website-api.oxy.so': 'website',
|
|
250
273
|
'api.mention.earth': 'mention',
|
|
251
274
|
'api.mercaria.co': 'mercaria',
|
|
275
|
+
// Old Alia app builds still call it; goes when api.alia.onl is retired (Gemies ADR 0034).
|
|
252
276
|
'api.alia.onl': 'alia',
|
|
277
|
+
'api.gemies.ai': 'gemies',
|
|
253
278
|
'kaana.ai': 'kaana',
|
|
254
279
|
'api.homiio.com': 'homiio',
|
|
255
280
|
'api.syra.fm': 'syra',
|
package/src/index.ts
CHANGED
|
@@ -1,18 +1,79 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* Request headers a browser may send and a CORS allow-list must therefore
|
|
12
|
+
* permit (`Access-Control-Allow-Headers`). `X-Oxy-Source-Region` is service to
|
|
13
|
+
* service only and is deliberately absent. The list is derived from the
|
|
14
|
+
* constants above, so a CORS list built from it cannot drift from them.
|
|
15
|
+
*/
|
|
16
|
+
export const OXY_TELEMETRY_REQUEST_HEADERS: readonly string[] = Object.freeze([
|
|
17
|
+
OXY_ACTIVITY_ID_HEADER,
|
|
18
|
+
OXY_EDGE_REGION_HEADER,
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Response headers a browser client may need to read
|
|
23
|
+
* (`Access-Control-Expose-Headers`).
|
|
24
|
+
*/
|
|
25
|
+
export const OXY_TELEMETRY_EXPOSED_HEADERS: readonly string[] = Object.freeze([OXY_REGION_HEADER]);
|
|
3
26
|
|
|
4
27
|
export interface AnonymousActivityMetadata {
|
|
5
28
|
activityId?: string;
|
|
6
29
|
edgePop?: string;
|
|
7
30
|
}
|
|
8
31
|
|
|
9
|
-
export
|
|
32
|
+
export type TrafficScope = 'internal' | 'external';
|
|
33
|
+
export type TrafficDirection = 'inbound' | 'outbound';
|
|
34
|
+
export type TrafficType = 'identity' | 'ai' | 'communication' | 'media' | 'platform';
|
|
35
|
+
|
|
36
|
+
/** One observed flow, as a reporter records it. */
|
|
37
|
+
export interface TrafficFlow {
|
|
38
|
+
region: string;
|
|
10
39
|
sourceRegion?: string;
|
|
11
|
-
targetRegion
|
|
40
|
+
targetRegion?: string;
|
|
41
|
+
sourceService?: string;
|
|
42
|
+
targetService?: string;
|
|
12
43
|
service: string;
|
|
44
|
+
scope: TrafficScope;
|
|
45
|
+
direction: TrafficDirection;
|
|
46
|
+
activityType: TrafficType;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A flow with its request count and window: the exact shape the API's strict
|
|
51
|
+
* `platformActivityBatchSchema` accepts on `POST /internal/activity`.
|
|
52
|
+
*/
|
|
53
|
+
export interface TrafficAggregate extends TrafficFlow {
|
|
13
54
|
requests: number;
|
|
14
|
-
activeClients: number;
|
|
15
55
|
windowStartedAt: string;
|
|
16
56
|
emittedAt: string;
|
|
17
|
-
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The `platform_activity` socket payload: an aggregate plus the approximate
|
|
61
|
+
* active-client count for its origin.
|
|
62
|
+
*/
|
|
63
|
+
export type ActivityAggregate = TrafficAggregate & { activeClients: number };
|
|
64
|
+
|
|
65
|
+
/** One region in the `platform_infrastructure` snapshot. */
|
|
66
|
+
export interface InfrastructureNode {
|
|
67
|
+
region: string;
|
|
68
|
+
label: string;
|
|
69
|
+
coordinates: [number, number];
|
|
70
|
+
services: string[];
|
|
71
|
+
instances: number;
|
|
72
|
+
status: 'online' | 'degraded' | 'offline' | 'unknown';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The `platform_infrastructure` socket payload. */
|
|
76
|
+
export interface InfrastructureSnapshot {
|
|
77
|
+
emittedAt: string;
|
|
78
|
+
nodes: InfrastructureNode[];
|
|
18
79
|
}
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operational metrics for Oxy services, published as CloudWatch Embedded Metric
|
|
3
|
+
* Format (EMF) log lines.
|
|
4
|
+
*
|
|
5
|
+
* WHY EMF ON STDOUT: every Oxy backend runs on ECS with the `awslogs` driver, and
|
|
6
|
+
* CloudWatch Logs extracts metrics from any log event shaped as EMF — no agent,
|
|
7
|
+
* no sidecar, no scraper, no IAM grant beyond the logging the task already has.
|
|
8
|
+
* A registry that only an HTTP scrape can read (Prometheus) measures into a void
|
|
9
|
+
* when nothing scrapes it, which is how Mention's counters lost every value on
|
|
10
|
+
* every deploy.
|
|
11
|
+
*
|
|
12
|
+
* WHY AGGREGATED: values are folded in memory and flushed once per interval as
|
|
13
|
+
* ONE line per (metric, label set), not one line per observation. A latency is
|
|
14
|
+
* kept as a distribution (`Values`/`Counts`), so CloudWatch still answers
|
|
15
|
+
* SampleCount, p50 and p99 for it — the request COUNT is the distribution's
|
|
16
|
+
* SampleCount, so a service needs no separate counter for it.
|
|
17
|
+
*
|
|
18
|
+
* WHY MOST LABELS ARE NOT DIMENSIONS: every distinct combination of dimension
|
|
19
|
+
* values is a billed custom metric. Route x method as dimensions once measured
|
|
20
|
+
* 405 metrics and ~80 % of the account's CloudWatch bill (Mention #1334). So a
|
|
21
|
+
* label becomes a DIMENSION only when a metric definition names it in
|
|
22
|
+
* `dimensionSets`, and only labels with a small, closed value set belong there.
|
|
23
|
+
* Every other label is written as a plain property of the line: free to store,
|
|
24
|
+
* and grouped in Logs Insights (`stats sum(RequestLatency.Count) by Route`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type MetricUnit =
|
|
28
|
+
| 'Count'
|
|
29
|
+
| 'Milliseconds'
|
|
30
|
+
| 'Seconds'
|
|
31
|
+
| 'Bytes'
|
|
32
|
+
| 'Percent'
|
|
33
|
+
| 'Count/Second'
|
|
34
|
+
| 'None';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `sum` adds every value in the interval (a counter), `distribution` keeps the
|
|
38
|
+
* values for percentiles (a latency or a size), and `gauge` keeps the last one.
|
|
39
|
+
*/
|
|
40
|
+
export type MetricKind = 'sum' | 'distribution' | 'gauge';
|
|
41
|
+
|
|
42
|
+
export interface MetricDefinition {
|
|
43
|
+
/** CloudWatch metric name; also the field holding its value on the line. */
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly unit: MetricUnit;
|
|
46
|
+
readonly kind: MetricKind;
|
|
47
|
+
/**
|
|
48
|
+
* Label names promoted to CloudWatch dimensions. Each inner list is one
|
|
49
|
+
* dimension set, published IN ADDITION to the always-present `[Service]`
|
|
50
|
+
* set. Only closed, small value sets belong here: each distinct combination
|
|
51
|
+
* is a billed custom metric.
|
|
52
|
+
*/
|
|
53
|
+
readonly dimensionSets?: readonly (readonly string[])[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type MetricLabels = Readonly<Record<string, string>>;
|
|
57
|
+
|
|
58
|
+
export interface EmfPublisherOptions {
|
|
59
|
+
/** CloudWatch namespace, e.g. `Oxy/Api`. */
|
|
60
|
+
readonly namespace: string;
|
|
61
|
+
/** Value of the `Service` dimension every metric carries. */
|
|
62
|
+
readonly service: string;
|
|
63
|
+
/** Flush period. Default 60 s, CloudWatch's standard resolution. */
|
|
64
|
+
readonly flushIntervalMs?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Distinct (metric, label set) series held per interval. A label value that
|
|
67
|
+
* traffic can mint must not be able to grow memory or log volume without
|
|
68
|
+
* bound, so past this many every NEW series is folded into one whose labels
|
|
69
|
+
* all read `other`. Default 2000.
|
|
70
|
+
*/
|
|
71
|
+
readonly maxSeries?: number;
|
|
72
|
+
/** Where each line goes. Default: one line on stdout. */
|
|
73
|
+
readonly write?: (line: string) => void;
|
|
74
|
+
/** Clock, for tests. */
|
|
75
|
+
readonly now?: () => number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface EmfPublisher {
|
|
79
|
+
/** Fold one observation into the current interval. Never throws. */
|
|
80
|
+
record(metric: MetricDefinition, value: number, labels?: MetricLabels): void;
|
|
81
|
+
/** Write the current interval now and start a new one. */
|
|
82
|
+
flush(): void;
|
|
83
|
+
/** Flush and stop the timer, for graceful shutdown. */
|
|
84
|
+
stop(): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One HTTP request: SampleCount is the request count, percentiles the latency. */
|
|
88
|
+
export const HTTP_REQUEST_LATENCY: MetricDefinition = {
|
|
89
|
+
name: 'RequestLatency',
|
|
90
|
+
unit: 'Milliseconds',
|
|
91
|
+
kind: 'distribution',
|
|
92
|
+
dimensionSets: [['StatusClass'], ['Caller']],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** One request a rate limiter refused (429), per calling application. */
|
|
96
|
+
export const HTTP_RATE_LIMITED: MetricDefinition = {
|
|
97
|
+
name: 'RateLimited',
|
|
98
|
+
unit: 'Count',
|
|
99
|
+
kind: 'sum',
|
|
100
|
+
dimensionSets: [['Caller']],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** `2xx`, `4xx`, … — a closed set, safe as a dimension. */
|
|
104
|
+
export function statusClass(status: number): string {
|
|
105
|
+
return Number.isInteger(status) && status >= 100 && status <= 599
|
|
106
|
+
? `${Math.floor(status / 100)}xx`
|
|
107
|
+
: 'other';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** EMF allows at most 100 distinct values per metric on one line. */
|
|
111
|
+
const MAX_DISTRIBUTION_VALUES = 100;
|
|
112
|
+
/** Neighbouring values within ~5 % share a bucket, which bounds the set. */
|
|
113
|
+
const BUCKET_BASE = 1.05;
|
|
114
|
+
const LABEL_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
115
|
+
const MAX_LABEL_VALUE_LENGTH = 128;
|
|
116
|
+
const RESERVED = new Set(['_aws', 'Service']);
|
|
117
|
+
|
|
118
|
+
interface Series {
|
|
119
|
+
metric: MetricDefinition;
|
|
120
|
+
labels: Record<string, string>;
|
|
121
|
+
sum: number;
|
|
122
|
+
last: number;
|
|
123
|
+
count: number;
|
|
124
|
+
min: number;
|
|
125
|
+
max: number;
|
|
126
|
+
buckets: Map<number, number>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Representative value of a bucket: values within ~5 % collapse together. */
|
|
130
|
+
function bucketOf(value: number): number {
|
|
131
|
+
if (value <= 0) return 0;
|
|
132
|
+
const exponent = Math.round(Math.log(value) / Math.log(BUCKET_BASE));
|
|
133
|
+
return Number((BUCKET_BASE ** exponent).toPrecision(3));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Merge adjacent buckets (keeping the larger value) until EMF accepts them. */
|
|
137
|
+
function boundedDistribution(buckets: Map<number, number>): { values: number[]; counts: number[] } {
|
|
138
|
+
let entries = [...buckets.entries()].sort(([left], [right]) => left - right);
|
|
139
|
+
while (entries.length > MAX_DISTRIBUTION_VALUES) {
|
|
140
|
+
const merged: [number, number][] = [];
|
|
141
|
+
for (let index = 0; index < entries.length; index += 2) {
|
|
142
|
+
const [value, count] = entries[index];
|
|
143
|
+
const next = entries[index + 1];
|
|
144
|
+
merged.push(next ? [next[0], count + next[1]] : [value, count]);
|
|
145
|
+
}
|
|
146
|
+
entries = merged;
|
|
147
|
+
}
|
|
148
|
+
return { values: entries.map(([value]) => value), counts: entries.map(([, count]) => count) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function cleanLabels(
|
|
152
|
+
metric: MetricDefinition,
|
|
153
|
+
labels: MetricLabels | undefined,
|
|
154
|
+
): Record<string, string> {
|
|
155
|
+
const clean: Record<string, string> = {};
|
|
156
|
+
for (const [name, raw] of Object.entries(labels ?? {})) {
|
|
157
|
+
if (!LABEL_NAME.test(name) || RESERVED.has(name) || name === metric.name) continue;
|
|
158
|
+
const value = String(raw).slice(0, MAX_LABEL_VALUE_LENGTH);
|
|
159
|
+
clean[name] = value.length > 0 ? value : 'unknown';
|
|
160
|
+
}
|
|
161
|
+
// A dimension named by the definition must be present on every line, or
|
|
162
|
+
// CloudWatch drops the metric from that line.
|
|
163
|
+
for (const set of metric.dimensionSets ?? []) {
|
|
164
|
+
for (const name of set) clean[name] ??= 'unknown';
|
|
165
|
+
}
|
|
166
|
+
return clean;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function seriesKey(metric: MetricDefinition, labels: Record<string, string>): string {
|
|
170
|
+
const pairs = Object.keys(labels)
|
|
171
|
+
.sort()
|
|
172
|
+
.map((name) => `${name}=${labels[name]}`);
|
|
173
|
+
return `${metric.name}\u0000${pairs.join('\u0000')}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface StdoutHost {
|
|
177
|
+
process?: { stdout?: { write?: (chunk: string) => unknown } };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One line on stdout. Reached through `globalThis`: this package carries no Node types. */
|
|
181
|
+
function defaultWrite(line: string): void {
|
|
182
|
+
(globalThis as StdoutHost).process?.stdout?.write?.(`${line}\n`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Set by ECS on every task: the one reliable signal that `awslogs` is capturing stdout. */
|
|
186
|
+
const ECS_METADATA_ENV = 'ECS_CONTAINER_METADATA_URI_V4';
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* `createEmfPublisher` on ECS, `null` everywhere else (a laptop, CI, a test):
|
|
190
|
+
* the gate oxy-api and Mention each repeated. `env` is injectable for tests.
|
|
191
|
+
*/
|
|
192
|
+
export function createEmfPublisherIfDeployed(
|
|
193
|
+
options: EmfPublisherOptions,
|
|
194
|
+
env: Readonly<Record<string, string | undefined>> = (
|
|
195
|
+
globalThis as { process?: { env?: Record<string, string | undefined> } }
|
|
196
|
+
).process?.env ?? {},
|
|
197
|
+
): EmfPublisher | null {
|
|
198
|
+
return env[ECS_METADATA_ENV] ? createEmfPublisher(options) : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createEmfPublisher(options: EmfPublisherOptions): EmfPublisher {
|
|
202
|
+
const flushIntervalMs = options.flushIntervalMs ?? 60_000;
|
|
203
|
+
const maxSeries = options.maxSeries ?? 2_000;
|
|
204
|
+
const write = options.write ?? defaultWrite;
|
|
205
|
+
const now = options.now ?? Date.now;
|
|
206
|
+
let series = new Map<string, Series>();
|
|
207
|
+
|
|
208
|
+
const line = (entry: Series, timestamp: number): string => {
|
|
209
|
+
const { metric } = entry;
|
|
210
|
+
const dimensions = [
|
|
211
|
+
['Service'],
|
|
212
|
+
...(metric.dimensionSets ?? []).map((set) => ['Service', ...set]),
|
|
213
|
+
];
|
|
214
|
+
let value: unknown;
|
|
215
|
+
if (metric.kind === 'sum') value = entry.sum;
|
|
216
|
+
else if (metric.kind === 'gauge') value = entry.last;
|
|
217
|
+
else {
|
|
218
|
+
const { values, counts } = boundedDistribution(entry.buckets);
|
|
219
|
+
value = {
|
|
220
|
+
Values: values,
|
|
221
|
+
Counts: counts,
|
|
222
|
+
Max: entry.max,
|
|
223
|
+
Min: entry.min,
|
|
224
|
+
Count: entry.count,
|
|
225
|
+
Sum: entry.sum,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return JSON.stringify({
|
|
229
|
+
_aws: {
|
|
230
|
+
Timestamp: timestamp,
|
|
231
|
+
CloudWatchMetrics: [
|
|
232
|
+
{
|
|
233
|
+
Namespace: options.namespace,
|
|
234
|
+
Dimensions: dimensions,
|
|
235
|
+
Metrics: [{ Name: metric.name, Unit: metric.unit }],
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
...entry.labels,
|
|
240
|
+
Service: options.service,
|
|
241
|
+
[metric.name]: value,
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const flush = (): void => {
|
|
246
|
+
if (series.size === 0) return;
|
|
247
|
+
const current = series;
|
|
248
|
+
series = new Map();
|
|
249
|
+
const timestamp = now();
|
|
250
|
+
for (const entry of current.values()) {
|
|
251
|
+
try {
|
|
252
|
+
write(line(entry, timestamp));
|
|
253
|
+
} catch {
|
|
254
|
+
// Metrics are best effort; a failed write must never reach a request.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const timer = setInterval(flush, flushIntervalMs);
|
|
260
|
+
(timer as { unref?: () => void }).unref?.();
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
record(metric, value, labels) {
|
|
264
|
+
if (!Number.isFinite(value)) return;
|
|
265
|
+
if (metric.kind !== 'gauge' && value < 0) return;
|
|
266
|
+
let clean = cleanLabels(metric, labels);
|
|
267
|
+
let key = seriesKey(metric, clean);
|
|
268
|
+
if (!series.has(key) && series.size >= maxSeries) {
|
|
269
|
+
clean = Object.fromEntries(Object.keys(clean).map((name) => [name, 'other']));
|
|
270
|
+
key = seriesKey(metric, clean);
|
|
271
|
+
}
|
|
272
|
+
let entry = series.get(key);
|
|
273
|
+
if (!entry) {
|
|
274
|
+
entry = {
|
|
275
|
+
metric,
|
|
276
|
+
labels: clean,
|
|
277
|
+
sum: 0,
|
|
278
|
+
last: 0,
|
|
279
|
+
count: 0,
|
|
280
|
+
min: Number.POSITIVE_INFINITY,
|
|
281
|
+
max: Number.NEGATIVE_INFINITY,
|
|
282
|
+
buckets: new Map(),
|
|
283
|
+
};
|
|
284
|
+
series.set(key, entry);
|
|
285
|
+
}
|
|
286
|
+
entry.sum += value;
|
|
287
|
+
entry.last = value;
|
|
288
|
+
entry.count += 1;
|
|
289
|
+
entry.min = Math.min(entry.min, value);
|
|
290
|
+
entry.max = Math.max(entry.max, value);
|
|
291
|
+
if (metric.kind === 'distribution') {
|
|
292
|
+
const bucket = bucketOf(value);
|
|
293
|
+
entry.buckets.set(bucket, (entry.buckets.get(bucket) ?? 0) + 1);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
flush,
|
|
297
|
+
stop() {
|
|
298
|
+
clearInterval(timer);
|
|
299
|
+
flush();
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type ActivityAggregate,
|
|
3
|
+
type AnonymousActivityMetadata,
|
|
4
|
+
OXY_ACTIVITY_ID_HEADER,
|
|
5
|
+
OXY_EDGE_REGION_HEADER,
|
|
6
|
+
} from './index.js';
|
|
2
7
|
|
|
3
8
|
export const DEFAULT_PRESENCE_BUCKET_MS = 30_000;
|
|
9
|
+
/** @deprecated Unused by any Oxy repo; `presenceKeys` already encodes the window. Removal is planned for the next major. */
|
|
4
10
|
export const DEFAULT_ACTIVE_CLIENT_WINDOW_MS = 60_000;
|
|
5
11
|
|
|
6
12
|
export type TelemetryHeaderValue = string | readonly string[] | undefined;
|
|
7
13
|
export type TelemetryHeaders = Readonly<Record<string, TelemetryHeaderValue>>;
|
|
8
14
|
|
|
15
|
+
/**
|
|
16
|
+
* @deprecated Unused by any Oxy repo (presence counting lives in the API). No
|
|
17
|
+
* replacement; removal is planned for the next major.
|
|
18
|
+
*/
|
|
9
19
|
export interface ActivityCardinalityAdapter {
|
|
10
20
|
add(key: string, activityId: string, ttlSeconds: number): Promise<void>;
|
|
11
21
|
count(keys: readonly string[]): Promise<number>;
|
|
12
22
|
}
|
|
13
23
|
|
|
24
|
+
/** @deprecated Unused by any Oxy repo; see `activityFlow`. */
|
|
14
25
|
export interface ActivityFlow {
|
|
15
26
|
sourceRegion?: string;
|
|
16
27
|
service: string;
|
|
@@ -34,26 +45,47 @@ export function normalizeActivityId(value: TelemetryHeaderValue): string | undef
|
|
|
34
45
|
return candidate && /^[a-z0-9_-]{16,64}$/i.test(candidate) ? candidate : undefined;
|
|
35
46
|
}
|
|
36
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The collector's source region for a normalised edge PoP (`edge-<pop>`), the
|
|
50
|
+
* one derivation every reporter shares. `undefined` when there is no PoP.
|
|
51
|
+
*/
|
|
52
|
+
export function sourceRegionFromEdgePop(edgePop: string | undefined): string | undefined {
|
|
53
|
+
return edgePop ? `edge-${edgePop}` : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
export function metadataFromHeaders(headers: TelemetryHeaders): AnonymousActivityMetadata {
|
|
38
|
-
const forwardedPop = normalizeEdgePop(headers[
|
|
57
|
+
const forwardedPop = normalizeEdgePop(headers[OXY_EDGE_REGION_HEADER.toLowerCase()]);
|
|
39
58
|
const ray = firstHeaderValue(headers['cf-ray']);
|
|
40
59
|
const rayPop = ray?.match(/-([a-z]{3})$/i)?.[1]?.toLowerCase();
|
|
41
|
-
const activityId = normalizeActivityId(headers[
|
|
60
|
+
const activityId = normalizeActivityId(headers[OXY_ACTIVITY_ID_HEADER.toLowerCase()]);
|
|
42
61
|
return {
|
|
43
62
|
...(activityId ? { activityId } : {}),
|
|
44
63
|
...(forwardedPop || rayPop ? { edgePop: forwardedPop ?? rayPop } : {}),
|
|
45
64
|
};
|
|
46
65
|
}
|
|
47
66
|
|
|
67
|
+
/**
|
|
68
|
+
* @deprecated Unused by any Oxy repo. For the bounded activity category of a
|
|
69
|
+
* path use `trafficType` from `@oxy.so/telemetry/collector`. Removal is planned
|
|
70
|
+
* for the next major.
|
|
71
|
+
*/
|
|
48
72
|
export function serviceFromPath(path: string): string {
|
|
49
73
|
const segment = path.split('/').filter(Boolean)[0];
|
|
50
74
|
return segment && /^[a-z][a-z0-9-]{0,31}$/i.test(segment) ? segment.toLowerCase() : 'platform';
|
|
51
75
|
}
|
|
52
76
|
|
|
77
|
+
/**
|
|
78
|
+
* @deprecated Unused by any Oxy repo; `createTrafficCollector` keys its own
|
|
79
|
+
* flows. Removal is planned for the next major.
|
|
80
|
+
*/
|
|
53
81
|
export function activityFlow(sourceRegion: string | undefined, service: string): ActivityFlow {
|
|
54
82
|
return { sourceRegion, service, key: `${sourceRegion ?? ''}|${service}` };
|
|
55
83
|
}
|
|
56
84
|
|
|
85
|
+
/**
|
|
86
|
+
* @deprecated Unused outside `presenceKeys`; call `presenceKeys` for the
|
|
87
|
+
* current and previous bucket keys. Removal is planned for the next major.
|
|
88
|
+
*/
|
|
57
89
|
export function presenceBucket(observedAt: number, bucketMs = DEFAULT_PRESENCE_BUCKET_MS): number {
|
|
58
90
|
if (!Number.isFinite(observedAt) || !Number.isFinite(bucketMs) || bucketMs <= 0) {
|
|
59
91
|
throw new RangeError('observedAt and bucketMs must define a finite positive bucket');
|
|
@@ -73,4 +105,5 @@ export function presenceKeys(
|
|
|
73
105
|
];
|
|
74
106
|
}
|
|
75
107
|
|
|
108
|
+
/** @deprecated Unused by any Oxy repo; use `ActivityAggregate` from `@oxy.so/telemetry`. */
|
|
76
109
|
export type ActivityBucket = ActivityAggregate;
|
package/src/socket.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { normalizeInfrastructureRegion, type TrafficFlow, type TrafficType } from './collector.js';
|
|
2
|
-
import { metadataFromHeaders, type TelemetryHeaders } from './server.js';
|
|
2
|
+
import { metadataFromHeaders, sourceRegionFromEdgePop, type TelemetryHeaders } from './server.js';
|
|
3
3
|
|
|
4
4
|
export interface TrafficSocket {
|
|
5
5
|
handshake: { headers: TelemetryHeaders };
|
|
@@ -63,9 +63,7 @@ export function observeTrafficSocket(
|
|
|
63
63
|
const edgePop = metadataFromHeaders(socket.handshake.headers).edgePop;
|
|
64
64
|
const peerRegion = peerService
|
|
65
65
|
? normalizeInfrastructureRegion(peer?.region)
|
|
66
|
-
: edgePop
|
|
67
|
-
? `edge-${edgePop}`
|
|
68
|
-
: undefined;
|
|
66
|
+
: sourceRegionFromEdgePop(edgePop);
|
|
69
67
|
collector.record({
|
|
70
68
|
region,
|
|
71
69
|
service,
|
|
@@ -125,9 +123,7 @@ export function observeTrafficWebSocket(
|
|
|
125
123
|
const edgePop = metadataFromHeaders(options.headers ?? {}).edgePop;
|
|
126
124
|
const peerRegion = peerService
|
|
127
125
|
? normalizeInfrastructureRegion(peer?.region)
|
|
128
|
-
: edgePop
|
|
129
|
-
? `edge-${edgePop}`
|
|
130
|
-
: undefined;
|
|
126
|
+
: sourceRegionFromEdgePop(edgePop);
|
|
131
127
|
collector.record({
|
|
132
128
|
service,
|
|
133
129
|
region,
|