@oxy.so/telemetry 1.2.0 → 2.1.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 +30 -0
- package/dist/cjs/browser.js +18 -8
- package/dist/cjs/collector.js +111 -26
- package/dist/cjs/metrics.js +208 -0
- package/dist/cjs/server.js +3 -1
- package/dist/cjs/socket.js +38 -9
- package/dist/esm/browser.js +18 -8
- package/dist/esm/collector.js +111 -26
- package/dist/esm/metrics.js +203 -0
- package/dist/esm/server.js +3 -1
- package/dist/esm/socket.js +38 -9
- package/dist/types/metrics.d.ts +79 -0
- package/dist/types/package.json +1 -0
- package/package.json +11 -12
- package/src/__tests__/browser.test.ts +22 -6
- package/src/__tests__/collector.test.ts +132 -27
- package/src/__tests__/metrics.test.ts +45 -0
- package/src/__tests__/server.test.ts +7 -5
- package/src/__tests__/socket.test.ts +80 -29
- package/src/browser.ts +35 -11
- package/src/collector.ts +152 -35
- package/src/metrics.ts +286 -0
- package/src/server.ts +8 -2
- package/src/socket.ts +43 -11
- package/dist/cjs/edge.js +0 -137
- package/dist/esm/edge.js +0 -134
- package/dist/types/edge.d.ts +0 -32
- package/src/__tests__/edge.test.ts +0 -110
- package/src/edge.ts +0 -117
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ Redis, React, React Native or Expo.
|
|
|
11
11
|
- `@oxy.so/telemetry/collector` — bounded HTTP/custom-transport flow aggregation.
|
|
12
12
|
- `@oxy.so/telemetry/server` — pure validation, route bucketing and cardinality
|
|
13
13
|
keys/interfaces.
|
|
14
|
+
- `@oxy.so/telemetry/metrics` — operational metrics as CloudWatch EMF lines.
|
|
14
15
|
|
|
15
16
|
Browser code must import the browser entry point explicitly. Server code must
|
|
16
17
|
import the server entry point explicitly; the root entry point has no platform
|
|
@@ -136,6 +137,35 @@ explicit infrastructure coordinates) and one of three ways to authenticate: an
|
|
|
136
137
|
where the process can attest its workload identity (ADR 0026). Credentials, user
|
|
137
138
|
metadata and paths never enter public snapshots.
|
|
138
139
|
|
|
140
|
+
## Operational metrics (CloudWatch EMF)
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { createEmfPublisher, HTTP_REQUEST_LATENCY, statusClass } from '@oxy.so/telemetry/metrics';
|
|
144
|
+
|
|
145
|
+
const metrics = createEmfPublisher({ namespace: 'Oxy/Api', service: 'oxy-api' });
|
|
146
|
+
metrics.record(HTTP_REQUEST_LATENCY, elapsedMs, {
|
|
147
|
+
Route: '/users/:id', // a log property: free, grouped in Logs Insights
|
|
148
|
+
StatusClass: statusClass(res.statusCode), // a dimension: billed per value
|
|
149
|
+
Caller: 'anonymous',
|
|
150
|
+
});
|
|
151
|
+
// On shutdown:
|
|
152
|
+
metrics.stop();
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Observations are aggregated in memory and written once per interval (60 s) as
|
|
156
|
+
one stdout line per (metric, label set). On ECS with the `awslogs` driver,
|
|
157
|
+
CloudWatch Logs extracts the metrics itself: no agent, sidecar or IAM grant.
|
|
158
|
+
A `distribution` keeps a ~5 % bucketed histogram, so CloudWatch answers
|
|
159
|
+
SampleCount (the count) and percentiles for it.
|
|
160
|
+
|
|
161
|
+
Every distinct combination of DIMENSION values is a billed custom metric, so a
|
|
162
|
+
label is a dimension only when the definition lists it in `dimensionSets`, and
|
|
163
|
+
only closed, small sets belong there (status class, a first-party caller).
|
|
164
|
+
Route, method, table and every other label stay plain properties on the line:
|
|
165
|
+
`stats sum(RequestLatency.Count) by Route` in Logs Insights. At most
|
|
166
|
+
`maxSeries` (default 2000) series are held per interval; past that, new series
|
|
167
|
+
fold into one whose labels read `other`.
|
|
168
|
+
|
|
139
169
|
## Development
|
|
140
170
|
|
|
141
171
|
```bash
|
package/dist/cjs/browser.js
CHANGED
|
@@ -26,9 +26,9 @@ function createUuid(cryptoApi) {
|
|
|
26
26
|
function eligibleLocation(location) {
|
|
27
27
|
if (!location || (location.protocol !== 'https:' && location.protocol !== 'http:'))
|
|
28
28
|
return false;
|
|
29
|
-
return location.hostname !== 'localhost'
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
return (location.hostname !== 'localhost' &&
|
|
30
|
+
location.hostname !== '127.0.0.1' &&
|
|
31
|
+
location.hostname !== '[::1]');
|
|
32
32
|
}
|
|
33
33
|
function createBrowserTelemetry(options = {}) {
|
|
34
34
|
let activityId = null;
|
|
@@ -41,7 +41,9 @@ function createBrowserTelemetry(options = {}) {
|
|
|
41
41
|
if (!hasInjectedBrowser && !hasBrowserRuntime)
|
|
42
42
|
return {};
|
|
43
43
|
const now = (options.now ?? Date.now)();
|
|
44
|
-
if (!activityId ||
|
|
44
|
+
if (!activityId ||
|
|
45
|
+
now < activityIdCreatedAt ||
|
|
46
|
+
now - activityIdCreatedAt >= (options.activityIdRotationMs ?? exports.DEFAULT_ACTIVITY_ID_ROTATION_MS)) {
|
|
45
47
|
activityId = createUuid(options.crypto ?? globalThis.crypto);
|
|
46
48
|
if (!activityId)
|
|
47
49
|
return {};
|
|
@@ -58,7 +60,11 @@ function createBrowserTelemetry(options = {}) {
|
|
|
58
60
|
try {
|
|
59
61
|
const response = await (options.fetchTrace
|
|
60
62
|
? options.fetchTrace(controller.signal)
|
|
61
|
-
: fetch('/cdn-cgi/trace', {
|
|
63
|
+
: fetch('/cdn-cgi/trace', {
|
|
64
|
+
cache: 'no-store',
|
|
65
|
+
credentials: 'omit',
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
}));
|
|
62
68
|
if (!response.ok)
|
|
63
69
|
return null;
|
|
64
70
|
const match = (await response.text()).match(/^colo=([a-z]{3})$/im);
|
|
@@ -73,7 +79,9 @@ function createBrowserTelemetry(options = {}) {
|
|
|
73
79
|
};
|
|
74
80
|
const getEdgeRegionHeader = async () => {
|
|
75
81
|
const now = (options.now ?? Date.now)();
|
|
76
|
-
if (!edgePopPromise ||
|
|
82
|
+
if (!edgePopPromise ||
|
|
83
|
+
now < edgePopReadAt ||
|
|
84
|
+
now - edgePopReadAt >= (options.edgeCacheMs ?? exports.DEFAULT_EDGE_CACHE_MS)) {
|
|
77
85
|
edgePopReadAt = now;
|
|
78
86
|
edgePopPromise = discoverEdgePop();
|
|
79
87
|
}
|
|
@@ -85,7 +93,9 @@ function createBrowserTelemetry(options = {}) {
|
|
|
85
93
|
let peekInFlight = false;
|
|
86
94
|
const peekEdgeRegionHeader = () => {
|
|
87
95
|
const now = (options.now ?? Date.now)();
|
|
88
|
-
if (!peekInFlight &&
|
|
96
|
+
if (!peekInFlight &&
|
|
97
|
+
(now < peekRefreshAt ||
|
|
98
|
+
now - peekRefreshAt >= (options.edgePeekRefreshMs ?? exports.DEFAULT_EDGE_PEEK_REFRESH_MS))) {
|
|
89
99
|
peekRefreshAt = now;
|
|
90
100
|
peekInFlight = true;
|
|
91
101
|
void discoverEdgePop()
|
|
@@ -104,7 +114,7 @@ function createBrowserTelemetry(options = {}) {
|
|
|
104
114
|
getEdgeRegionHeader,
|
|
105
115
|
peekEdgeRegionHeader,
|
|
106
116
|
async getHeaders() {
|
|
107
|
-
return { ...await getEdgeRegionHeader(), ...getActivityIdHeader() };
|
|
117
|
+
return { ...(await getEdgeRegionHeader()), ...getActivityIdHeader() };
|
|
108
118
|
},
|
|
109
119
|
};
|
|
110
120
|
}
|
package/dist/cjs/collector.js
CHANGED
|
@@ -9,11 +9,34 @@ exports.infrastructureLocation = infrastructureLocation;
|
|
|
9
9
|
exports.normalizeInfrastructureRegion = normalizeInfrastructureRegion;
|
|
10
10
|
const server_js_1 = require("./server.js");
|
|
11
11
|
const TYPES = {
|
|
12
|
-
auth: 'identity',
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
auth: 'identity',
|
|
13
|
+
session: 'identity',
|
|
14
|
+
users: 'identity',
|
|
15
|
+
profiles: 'identity',
|
|
16
|
+
accounts: 'identity',
|
|
17
|
+
ai: 'ai',
|
|
18
|
+
inference: 'ai',
|
|
19
|
+
models: 'ai',
|
|
20
|
+
chat: 'ai',
|
|
21
|
+
completions: 'ai',
|
|
22
|
+
stream: 'media',
|
|
23
|
+
tracks: 'media',
|
|
24
|
+
episodes: 'media',
|
|
25
|
+
recordings: 'media',
|
|
26
|
+
rooms: 'media',
|
|
27
|
+
messages: 'communication',
|
|
28
|
+
notifications: 'communication',
|
|
29
|
+
mail: 'communication',
|
|
30
|
+
media: 'media',
|
|
31
|
+
files: 'media',
|
|
32
|
+
assets: 'media',
|
|
33
|
+
upload: 'media',
|
|
34
|
+
uploads: 'media',
|
|
35
|
+
cdn: 'media',
|
|
36
|
+
storage: 'media',
|
|
37
|
+
images: 'media',
|
|
38
|
+
audio: 'media',
|
|
39
|
+
videos: 'media',
|
|
17
40
|
};
|
|
18
41
|
function trafficType(path) {
|
|
19
42
|
const parts = path.split('?')[0].split('/').filter(Boolean);
|
|
@@ -43,7 +66,12 @@ function createTrafficCollector(publish, now = Date.now) {
|
|
|
43
66
|
if (pending.size === 0)
|
|
44
67
|
return;
|
|
45
68
|
const emittedAt = new Date(now()).toISOString();
|
|
46
|
-
const events = [...pending.values()].map(({ flow, requests, startedAt }) => ({
|
|
69
|
+
const events = [...pending.values()].map(({ flow, requests, startedAt }) => ({
|
|
70
|
+
...flow,
|
|
71
|
+
requests,
|
|
72
|
+
windowStartedAt: new Date(startedAt).toISOString(),
|
|
73
|
+
emittedAt,
|
|
74
|
+
}));
|
|
47
75
|
pending.clear();
|
|
48
76
|
flushing = (async () => {
|
|
49
77
|
for (let offset = 0; offset < events.length; offset += 256)
|
|
@@ -63,7 +91,10 @@ function trafficMiddleware(collector, service, region) {
|
|
|
63
91
|
response.setHeader?.('X-Oxy-Region', region);
|
|
64
92
|
const path = request.path ?? request.url ?? '/';
|
|
65
93
|
// Never report collection itself, probes or the dashboard transport.
|
|
66
|
-
if (/^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|platform-infrastructure|infra-status)(?:\/|$)/.test(path) ||
|
|
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/')) {
|
|
67
98
|
next();
|
|
68
99
|
return;
|
|
69
100
|
}
|
|
@@ -72,11 +103,28 @@ function trafficMiddleware(collector, service, region) {
|
|
|
72
103
|
let finished = false;
|
|
73
104
|
const inboundFlow = () => {
|
|
74
105
|
// Read service identity AFTER the route's shared auth middleware resolved it.
|
|
75
|
-
const name = request.serviceApp?.appName
|
|
106
|
+
const name = request.serviceApp?.appName
|
|
107
|
+
?.toLowerCase()
|
|
108
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
109
|
+
.slice(0, 40);
|
|
76
110
|
const internal = Boolean(name);
|
|
77
111
|
const forwardedRegion = request.headers['x-oxy-source-region'];
|
|
78
|
-
const peerRegion = internal
|
|
79
|
-
|
|
112
|
+
const peerRegion = internal
|
|
113
|
+
? normalizeInfrastructureRegion(forwardedRegion)
|
|
114
|
+
: edgePop
|
|
115
|
+
? `edge-${edgePop}`
|
|
116
|
+
: undefined;
|
|
117
|
+
return {
|
|
118
|
+
region,
|
|
119
|
+
service,
|
|
120
|
+
activityType,
|
|
121
|
+
scope: internal ? 'internal' : 'external',
|
|
122
|
+
direction: 'inbound',
|
|
123
|
+
sourceRegion: peerRegion,
|
|
124
|
+
targetRegion: region,
|
|
125
|
+
sourceService: name,
|
|
126
|
+
targetService: service,
|
|
127
|
+
};
|
|
80
128
|
};
|
|
81
129
|
const finish = () => {
|
|
82
130
|
if (finished)
|
|
@@ -84,7 +132,14 @@ function trafficMiddleware(collector, service, region) {
|
|
|
84
132
|
finished = true;
|
|
85
133
|
const flow = inboundFlow();
|
|
86
134
|
collector.record(flow);
|
|
87
|
-
collector.record({
|
|
135
|
+
collector.record({
|
|
136
|
+
...flow,
|
|
137
|
+
direction: 'outbound',
|
|
138
|
+
sourceRegion: region,
|
|
139
|
+
targetRegion: flow.sourceRegion,
|
|
140
|
+
sourceService: service,
|
|
141
|
+
targetService: flow.sourceService,
|
|
142
|
+
});
|
|
88
143
|
};
|
|
89
144
|
response.once('finish', finish);
|
|
90
145
|
// Failed/aborted requests are still real inbound activity. Do not invent a
|
|
@@ -102,13 +157,21 @@ function instrumentTrafficFetch(fetcher, collector, service, region, resolveEndp
|
|
|
102
157
|
return Object.assign(async (input, init) => {
|
|
103
158
|
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
|
|
104
159
|
// Collection/auth control traffic cannot generate a feedback loop.
|
|
105
|
-
if (url.pathname.startsWith('/internal/activity') ||
|
|
160
|
+
if (url.pathname.startsWith('/internal/activity') ||
|
|
161
|
+
url.pathname.startsWith('/auth/service-token') ||
|
|
162
|
+
url.pathname === '/platform-infrastructure')
|
|
106
163
|
return fetcher(input, init);
|
|
107
164
|
const peer = resolveEndpoint(url);
|
|
108
165
|
const flow = {
|
|
109
|
-
service,
|
|
110
|
-
|
|
111
|
-
|
|
166
|
+
service,
|
|
167
|
+
region,
|
|
168
|
+
scope: peer ? 'internal' : 'external',
|
|
169
|
+
direction: 'outbound',
|
|
170
|
+
activityType: trafficType(url.pathname),
|
|
171
|
+
sourceRegion: region,
|
|
172
|
+
targetRegion: peer?.region,
|
|
173
|
+
sourceService: service,
|
|
174
|
+
targetService: peer?.service,
|
|
112
175
|
};
|
|
113
176
|
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
114
177
|
if (peer)
|
|
@@ -121,23 +184,43 @@ function instrumentTrafficFetch(fetcher, collector, service, region, resolveEndp
|
|
|
121
184
|
collector.record({ ...flow, targetRegion: undefined });
|
|
122
185
|
throw error;
|
|
123
186
|
}
|
|
124
|
-
const targetRegion = peer
|
|
187
|
+
const targetRegion = peer
|
|
188
|
+
? normalizeInfrastructureRegion(response.headers.get('X-Oxy-Region') ?? undefined)
|
|
189
|
+
: undefined;
|
|
125
190
|
collector.record({ ...flow, targetRegion });
|
|
126
|
-
collector.record({
|
|
191
|
+
collector.record({
|
|
192
|
+
...flow,
|
|
193
|
+
direction: 'inbound',
|
|
194
|
+
sourceRegion: targetRegion,
|
|
195
|
+
targetRegion: region,
|
|
196
|
+
sourceService: peer?.service,
|
|
197
|
+
targetService: service,
|
|
198
|
+
});
|
|
127
199
|
return response;
|
|
128
200
|
}, fetcher);
|
|
129
201
|
}
|
|
130
202
|
// Exact deployed API hosts; an arbitrary caller-supplied hostname cannot claim
|
|
131
203
|
// to be an internal service. Region is infrastructure metadata, never IP geo.
|
|
132
204
|
const OXY_API_SERVICES = {
|
|
133
|
-
'api.oxy.so': 'oxy-api',
|
|
134
|
-
'
|
|
135
|
-
'api.
|
|
136
|
-
'api.
|
|
137
|
-
'
|
|
138
|
-
'api.
|
|
139
|
-
'
|
|
140
|
-
'api.
|
|
205
|
+
'api.oxy.so': 'oxy-api',
|
|
206
|
+
'api.website.oxy.so': 'website',
|
|
207
|
+
'website-api.oxy.so': 'website',
|
|
208
|
+
'api.mention.earth': 'mention',
|
|
209
|
+
'api.mercaria.co': 'mercaria',
|
|
210
|
+
'api.alia.onl': 'alia',
|
|
211
|
+
'kaana.ai': 'kaana',
|
|
212
|
+
'api.homiio.com': 'homiio',
|
|
213
|
+
'api.syra.fm': 'syra',
|
|
214
|
+
'mention.earth': 'mention',
|
|
215
|
+
'mcp.mention.earth': 'mention-mcp',
|
|
216
|
+
'api.clarity.surf': 'clarity',
|
|
217
|
+
'api.peable.to': 'peable',
|
|
218
|
+
'api.crowdsource.oxy.so': 'crowdsource',
|
|
219
|
+
'api.moovo.now': 'moovo',
|
|
220
|
+
'api.allo.you': 'allo',
|
|
221
|
+
'api.noted.oxy.so': 'noted',
|
|
222
|
+
'api.schedio.app': 'schedio',
|
|
223
|
+
'api.tnp.network': 'tnp-api',
|
|
141
224
|
};
|
|
142
225
|
function resolveOxyServiceEndpoint(url) {
|
|
143
226
|
const service = OXY_API_SERVICES[url.hostname];
|
|
@@ -174,7 +257,9 @@ const REGION_LOCATIONS = {
|
|
|
174
257
|
'me-central-1': { label: 'UAE', coordinates: [54.37, 24.45] },
|
|
175
258
|
'il-central-1': { label: 'Tel Aviv', coordinates: [34.78, 32.09] },
|
|
176
259
|
};
|
|
177
|
-
function infrastructureLocation(region) {
|
|
260
|
+
function infrastructureLocation(region) {
|
|
261
|
+
return REGION_LOCATIONS[region];
|
|
262
|
+
}
|
|
178
263
|
function normalizeInfrastructureRegion(value) {
|
|
179
264
|
return typeof value === 'string' && /^[a-z]{2}(?:-[a-z]+)+-\d$/.test(value) ? value : undefined;
|
|
180
265
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
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.createEmfPublisher = createEmfPublisher;
|
|
31
|
+
/** One HTTP request: SampleCount is the request count, percentiles the latency. */
|
|
32
|
+
exports.HTTP_REQUEST_LATENCY = {
|
|
33
|
+
name: 'RequestLatency',
|
|
34
|
+
unit: 'Milliseconds',
|
|
35
|
+
kind: 'distribution',
|
|
36
|
+
dimensionSets: [['StatusClass'], ['Caller']],
|
|
37
|
+
};
|
|
38
|
+
/** One request a rate limiter refused (429), per calling application. */
|
|
39
|
+
exports.HTTP_RATE_LIMITED = {
|
|
40
|
+
name: 'RateLimited',
|
|
41
|
+
unit: 'Count',
|
|
42
|
+
kind: 'sum',
|
|
43
|
+
dimensionSets: [['Caller']],
|
|
44
|
+
};
|
|
45
|
+
/** `2xx`, `4xx`, … — a closed set, safe as a dimension. */
|
|
46
|
+
function statusClass(status) {
|
|
47
|
+
return Number.isInteger(status) && status >= 100 && status <= 599
|
|
48
|
+
? `${Math.floor(status / 100)}xx`
|
|
49
|
+
: 'other';
|
|
50
|
+
}
|
|
51
|
+
/** EMF allows at most 100 distinct values per metric on one line. */
|
|
52
|
+
const MAX_DISTRIBUTION_VALUES = 100;
|
|
53
|
+
/** Neighbouring values within ~5 % share a bucket, which bounds the set. */
|
|
54
|
+
const BUCKET_BASE = 1.05;
|
|
55
|
+
const LABEL_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
56
|
+
const MAX_LABEL_VALUE_LENGTH = 128;
|
|
57
|
+
const RESERVED = new Set(['_aws', 'Service']);
|
|
58
|
+
/** Representative value of a bucket: values within ~5 % collapse together. */
|
|
59
|
+
function bucketOf(value) {
|
|
60
|
+
if (value <= 0)
|
|
61
|
+
return 0;
|
|
62
|
+
const exponent = Math.round(Math.log(value) / Math.log(BUCKET_BASE));
|
|
63
|
+
return Number((BUCKET_BASE ** exponent).toPrecision(3));
|
|
64
|
+
}
|
|
65
|
+
/** Merge adjacent buckets (keeping the larger value) until EMF accepts them. */
|
|
66
|
+
function boundedDistribution(buckets) {
|
|
67
|
+
let entries = [...buckets.entries()].sort(([left], [right]) => left - right);
|
|
68
|
+
while (entries.length > MAX_DISTRIBUTION_VALUES) {
|
|
69
|
+
const merged = [];
|
|
70
|
+
for (let index = 0; index < entries.length; index += 2) {
|
|
71
|
+
const [value, count] = entries[index];
|
|
72
|
+
const next = entries[index + 1];
|
|
73
|
+
merged.push(next ? [next[0], count + next[1]] : [value, count]);
|
|
74
|
+
}
|
|
75
|
+
entries = merged;
|
|
76
|
+
}
|
|
77
|
+
return { values: entries.map(([value]) => value), counts: entries.map(([, count]) => count) };
|
|
78
|
+
}
|
|
79
|
+
function cleanLabels(metric, labels) {
|
|
80
|
+
const clean = {};
|
|
81
|
+
for (const [name, raw] of Object.entries(labels ?? {})) {
|
|
82
|
+
if (!LABEL_NAME.test(name) || RESERVED.has(name) || name === metric.name)
|
|
83
|
+
continue;
|
|
84
|
+
const value = String(raw).slice(0, MAX_LABEL_VALUE_LENGTH);
|
|
85
|
+
clean[name] = value.length > 0 ? value : 'unknown';
|
|
86
|
+
}
|
|
87
|
+
// A dimension named by the definition must be present on every line, or
|
|
88
|
+
// CloudWatch drops the metric from that line.
|
|
89
|
+
for (const set of metric.dimensionSets ?? []) {
|
|
90
|
+
for (const name of set)
|
|
91
|
+
clean[name] ?? (clean[name] = 'unknown');
|
|
92
|
+
}
|
|
93
|
+
return clean;
|
|
94
|
+
}
|
|
95
|
+
function seriesKey(metric, labels) {
|
|
96
|
+
const pairs = Object.keys(labels)
|
|
97
|
+
.sort()
|
|
98
|
+
.map((name) => `${name}=${labels[name]}`);
|
|
99
|
+
return `${metric.name}\u0000${pairs.join('\u0000')}`;
|
|
100
|
+
}
|
|
101
|
+
/** One line on stdout. Reached through `globalThis`: this package carries no Node types. */
|
|
102
|
+
function defaultWrite(line) {
|
|
103
|
+
globalThis.process?.stdout?.write?.(`${line}\n`);
|
|
104
|
+
}
|
|
105
|
+
function createEmfPublisher(options) {
|
|
106
|
+
const flushIntervalMs = options.flushIntervalMs ?? 60000;
|
|
107
|
+
const maxSeries = options.maxSeries ?? 2000;
|
|
108
|
+
const write = options.write ?? defaultWrite;
|
|
109
|
+
const now = options.now ?? Date.now;
|
|
110
|
+
let series = new Map();
|
|
111
|
+
const line = (entry, timestamp) => {
|
|
112
|
+
const { metric } = entry;
|
|
113
|
+
const dimensions = [
|
|
114
|
+
['Service'],
|
|
115
|
+
...(metric.dimensionSets ?? []).map((set) => ['Service', ...set]),
|
|
116
|
+
];
|
|
117
|
+
let value;
|
|
118
|
+
if (metric.kind === 'sum')
|
|
119
|
+
value = entry.sum;
|
|
120
|
+
else if (metric.kind === 'gauge')
|
|
121
|
+
value = entry.last;
|
|
122
|
+
else {
|
|
123
|
+
const { values, counts } = boundedDistribution(entry.buckets);
|
|
124
|
+
value = {
|
|
125
|
+
Values: values,
|
|
126
|
+
Counts: counts,
|
|
127
|
+
Max: entry.max,
|
|
128
|
+
Min: entry.min,
|
|
129
|
+
Count: entry.count,
|
|
130
|
+
Sum: entry.sum,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return JSON.stringify({
|
|
134
|
+
_aws: {
|
|
135
|
+
Timestamp: timestamp,
|
|
136
|
+
CloudWatchMetrics: [
|
|
137
|
+
{
|
|
138
|
+
Namespace: options.namespace,
|
|
139
|
+
Dimensions: dimensions,
|
|
140
|
+
Metrics: [{ Name: metric.name, Unit: metric.unit }],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
...entry.labels,
|
|
145
|
+
Service: options.service,
|
|
146
|
+
[metric.name]: value,
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
const flush = () => {
|
|
150
|
+
if (series.size === 0)
|
|
151
|
+
return;
|
|
152
|
+
const current = series;
|
|
153
|
+
series = new Map();
|
|
154
|
+
const timestamp = now();
|
|
155
|
+
for (const entry of current.values()) {
|
|
156
|
+
try {
|
|
157
|
+
write(line(entry, timestamp));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Metrics are best effort; a failed write must never reach a request.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const timer = setInterval(flush, flushIntervalMs);
|
|
165
|
+
timer.unref?.();
|
|
166
|
+
return {
|
|
167
|
+
record(metric, value, labels) {
|
|
168
|
+
if (!Number.isFinite(value))
|
|
169
|
+
return;
|
|
170
|
+
if (metric.kind !== 'gauge' && value < 0)
|
|
171
|
+
return;
|
|
172
|
+
let clean = cleanLabels(metric, labels);
|
|
173
|
+
let key = seriesKey(metric, clean);
|
|
174
|
+
if (!series.has(key) && series.size >= maxSeries) {
|
|
175
|
+
clean = Object.fromEntries(Object.keys(clean).map((name) => [name, 'other']));
|
|
176
|
+
key = seriesKey(metric, clean);
|
|
177
|
+
}
|
|
178
|
+
let entry = series.get(key);
|
|
179
|
+
if (!entry) {
|
|
180
|
+
entry = {
|
|
181
|
+
metric,
|
|
182
|
+
labels: clean,
|
|
183
|
+
sum: 0,
|
|
184
|
+
last: 0,
|
|
185
|
+
count: 0,
|
|
186
|
+
min: Number.POSITIVE_INFINITY,
|
|
187
|
+
max: Number.NEGATIVE_INFINITY,
|
|
188
|
+
buckets: new Map(),
|
|
189
|
+
};
|
|
190
|
+
series.set(key, entry);
|
|
191
|
+
}
|
|
192
|
+
entry.sum += value;
|
|
193
|
+
entry.last = value;
|
|
194
|
+
entry.count += 1;
|
|
195
|
+
entry.min = Math.min(entry.min, value);
|
|
196
|
+
entry.max = Math.max(entry.max, value);
|
|
197
|
+
if (metric.kind === 'distribution') {
|
|
198
|
+
const bucket = bucketOf(value);
|
|
199
|
+
entry.buckets.set(bucket, (entry.buckets.get(bucket) ?? 0) + 1);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
flush,
|
|
203
|
+
stop() {
|
|
204
|
+
clearInterval(timer);
|
|
205
|
+
flush();
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
package/dist/cjs/server.js
CHANGED
|
@@ -17,7 +17,9 @@ function firstHeaderValue(value) {
|
|
|
17
17
|
return typeof first === 'string' ? first : undefined;
|
|
18
18
|
}
|
|
19
19
|
function normalizeEdgePop(value) {
|
|
20
|
-
return firstHeaderValue(value)
|
|
20
|
+
return firstHeaderValue(value)
|
|
21
|
+
?.match(/^[a-z]{3}$/i)?.[0]
|
|
22
|
+
?.toLowerCase();
|
|
21
23
|
}
|
|
22
24
|
function normalizeActivityId(value) {
|
|
23
25
|
const candidate = firstHeaderValue(value);
|
package/dist/cjs/socket.js
CHANGED
|
@@ -6,11 +6,26 @@ exports.observeTrafficWebSocket = observeTrafficWebSocket;
|
|
|
6
6
|
const collector_js_1 = require("./collector.js");
|
|
7
7
|
const server_js_1 = require("./server.js");
|
|
8
8
|
const CATEGORIES = {
|
|
9
|
-
auth: 'identity',
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
auth: 'identity',
|
|
10
|
+
session: 'identity',
|
|
11
|
+
user: 'identity',
|
|
12
|
+
profile: 'identity',
|
|
13
|
+
chat: 'ai',
|
|
14
|
+
agent: 'ai',
|
|
15
|
+
inference: 'ai',
|
|
16
|
+
completion: 'ai',
|
|
17
|
+
message: 'communication',
|
|
18
|
+
notification: 'communication',
|
|
19
|
+
typing: 'communication',
|
|
20
|
+
media: 'media',
|
|
21
|
+
audio: 'media',
|
|
22
|
+
video: 'media',
|
|
23
|
+
stream: 'media',
|
|
24
|
+
player: 'media',
|
|
25
|
+
track: 'media',
|
|
26
|
+
playback: 'media',
|
|
27
|
+
playlist: 'media',
|
|
28
|
+
room: 'media',
|
|
14
29
|
};
|
|
15
30
|
/** Event names are reduced locally to five categories; payloads are never read. */
|
|
16
31
|
function socketTrafficType(event) {
|
|
@@ -32,9 +47,16 @@ function observeTrafficSocket(socket, collector, service, region, options = {})
|
|
|
32
47
|
const peer = options.getPeer?.();
|
|
33
48
|
const peerService = peer && /^[a-z][a-z0-9-]{0,39}$/.test(peer.service) ? peer.service : undefined;
|
|
34
49
|
const edgePop = (0, server_js_1.metadataFromHeaders)(socket.handshake.headers).edgePop;
|
|
35
|
-
const peerRegion = peerService
|
|
50
|
+
const peerRegion = peerService
|
|
51
|
+
? (0, collector_js_1.normalizeInfrastructureRegion)(peer?.region)
|
|
52
|
+
: edgePop
|
|
53
|
+
? `edge-${edgePop}`
|
|
54
|
+
: undefined;
|
|
36
55
|
collector.record({
|
|
37
|
-
region,
|
|
56
|
+
region,
|
|
57
|
+
service,
|
|
58
|
+
scope: peerService ? 'internal' : 'external',
|
|
59
|
+
direction,
|
|
38
60
|
activityType: socketTrafficType(event),
|
|
39
61
|
sourceRegion: direction === 'inbound' ? peerRegion : region,
|
|
40
62
|
targetRegion: direction === 'inbound' ? region : peerRegion,
|
|
@@ -66,9 +88,16 @@ function observeTrafficWebSocket(socket, collector, service, region, options = {
|
|
|
66
88
|
const peer = options.getPeer?.();
|
|
67
89
|
const peerService = peer && /^[a-z][a-z0-9-]{0,39}$/.test(peer.service) ? peer.service : undefined;
|
|
68
90
|
const edgePop = (0, server_js_1.metadataFromHeaders)(options.headers ?? {}).edgePop;
|
|
69
|
-
const peerRegion = peerService
|
|
91
|
+
const peerRegion = peerService
|
|
92
|
+
? (0, collector_js_1.normalizeInfrastructureRegion)(peer?.region)
|
|
93
|
+
: edgePop
|
|
94
|
+
? `edge-${edgePop}`
|
|
95
|
+
: undefined;
|
|
70
96
|
collector.record({
|
|
71
|
-
service,
|
|
97
|
+
service,
|
|
98
|
+
region,
|
|
99
|
+
scope: peerService ? 'internal' : 'external',
|
|
100
|
+
direction,
|
|
72
101
|
activityType: options.activityType ?? 'communication',
|
|
73
102
|
sourceRegion: direction === 'inbound' ? peerRegion : region,
|
|
74
103
|
targetRegion: direction === 'inbound' ? region : peerRegion,
|