@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/src/browser.ts
CHANGED
|
@@ -38,7 +38,9 @@ export interface BrowserTelemetry {
|
|
|
38
38
|
getHeaders(): Promise<Record<string, string>>;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function createUuid(
|
|
41
|
+
function createUuid(
|
|
42
|
+
cryptoApi: Pick<Crypto, 'getRandomValues' | 'randomUUID'> | undefined,
|
|
43
|
+
): string | null {
|
|
42
44
|
if (!cryptoApi) return null;
|
|
43
45
|
if (typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID();
|
|
44
46
|
|
|
@@ -51,9 +53,11 @@ function createUuid(cryptoApi: Pick<Crypto, 'getRandomValues' | 'randomUUID'> |
|
|
|
51
53
|
|
|
52
54
|
function eligibleLocation(location: Pick<Location, 'hostname' | 'protocol'> | undefined): boolean {
|
|
53
55
|
if (!location || (location.protocol !== 'https:' && location.protocol !== 'http:')) return false;
|
|
54
|
-
return
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
return (
|
|
57
|
+
location.hostname !== 'localhost' &&
|
|
58
|
+
location.hostname !== '127.0.0.1' &&
|
|
59
|
+
location.hostname !== '[::1]'
|
|
60
|
+
);
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
export function createBrowserTelemetry(options: BrowserTelemetryOptions = {}): BrowserTelemetry {
|
|
@@ -67,7 +71,11 @@ export function createBrowserTelemetry(options: BrowserTelemetryOptions = {}): B
|
|
|
67
71
|
const hasBrowserRuntime = typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
68
72
|
if (!hasInjectedBrowser && !hasBrowserRuntime) return {};
|
|
69
73
|
const now = (options.now ?? Date.now)();
|
|
70
|
-
if (
|
|
74
|
+
if (
|
|
75
|
+
!activityId ||
|
|
76
|
+
now < activityIdCreatedAt ||
|
|
77
|
+
now - activityIdCreatedAt >= (options.activityIdRotationMs ?? DEFAULT_ACTIVITY_ID_ROTATION_MS)
|
|
78
|
+
) {
|
|
71
79
|
activityId = createUuid(options.crypto ?? globalThis.crypto);
|
|
72
80
|
if (!activityId) return {};
|
|
73
81
|
activityIdCreatedAt = now;
|
|
@@ -76,14 +84,22 @@ export function createBrowserTelemetry(options: BrowserTelemetryOptions = {}): B
|
|
|
76
84
|
};
|
|
77
85
|
|
|
78
86
|
const discoverEdgePop = async (): Promise<string | null> => {
|
|
79
|
-
const browserLocation =
|
|
87
|
+
const browserLocation =
|
|
88
|
+
options.location ?? (typeof window === 'undefined' ? undefined : window.location);
|
|
80
89
|
if (!eligibleLocation(browserLocation)) return null;
|
|
81
90
|
const controller = new AbortController();
|
|
82
|
-
const timeout = setTimeout(
|
|
91
|
+
const timeout = setTimeout(
|
|
92
|
+
() => controller.abort(),
|
|
93
|
+
options.edgeTraceTimeoutMs ?? DEFAULT_EDGE_TRACE_TIMEOUT_MS,
|
|
94
|
+
);
|
|
83
95
|
try {
|
|
84
96
|
const response = await (options.fetchTrace
|
|
85
97
|
? options.fetchTrace(controller.signal)
|
|
86
|
-
: fetch('/cdn-cgi/trace', {
|
|
98
|
+
: fetch('/cdn-cgi/trace', {
|
|
99
|
+
cache: 'no-store',
|
|
100
|
+
credentials: 'omit',
|
|
101
|
+
signal: controller.signal,
|
|
102
|
+
}));
|
|
87
103
|
if (!response.ok) return null;
|
|
88
104
|
const match = (await response.text()).match(/^colo=([a-z]{3})$/im);
|
|
89
105
|
return match ? match[1].toLowerCase() : null;
|
|
@@ -96,7 +112,11 @@ export function createBrowserTelemetry(options: BrowserTelemetryOptions = {}): B
|
|
|
96
112
|
|
|
97
113
|
const getEdgeRegionHeader = async (): Promise<Record<string, string>> => {
|
|
98
114
|
const now = (options.now ?? Date.now)();
|
|
99
|
-
if (
|
|
115
|
+
if (
|
|
116
|
+
!edgePopPromise ||
|
|
117
|
+
now < edgePopReadAt ||
|
|
118
|
+
now - edgePopReadAt >= (options.edgeCacheMs ?? DEFAULT_EDGE_CACHE_MS)
|
|
119
|
+
) {
|
|
100
120
|
edgePopReadAt = now;
|
|
101
121
|
edgePopPromise = discoverEdgePop();
|
|
102
122
|
}
|
|
@@ -110,7 +130,11 @@ export function createBrowserTelemetry(options: BrowserTelemetryOptions = {}): B
|
|
|
110
130
|
|
|
111
131
|
const peekEdgeRegionHeader = (): Record<string, string> => {
|
|
112
132
|
const now = (options.now ?? Date.now)();
|
|
113
|
-
if (
|
|
133
|
+
if (
|
|
134
|
+
!peekInFlight &&
|
|
135
|
+
(now < peekRefreshAt ||
|
|
136
|
+
now - peekRefreshAt >= (options.edgePeekRefreshMs ?? DEFAULT_EDGE_PEEK_REFRESH_MS))
|
|
137
|
+
) {
|
|
114
138
|
peekRefreshAt = now;
|
|
115
139
|
peekInFlight = true;
|
|
116
140
|
void discoverEdgePop()
|
|
@@ -129,7 +153,7 @@ export function createBrowserTelemetry(options: BrowserTelemetryOptions = {}): B
|
|
|
129
153
|
getEdgeRegionHeader,
|
|
130
154
|
peekEdgeRegionHeader,
|
|
131
155
|
async getHeaders(): Promise<Record<string, string>> {
|
|
132
|
-
return { ...await getEdgeRegionHeader(), ...getActivityIdHeader() };
|
|
156
|
+
return { ...(await getEdgeRegionHeader()), ...getActivityIdHeader() };
|
|
133
157
|
},
|
|
134
158
|
};
|
|
135
159
|
}
|
package/src/collector.ts
CHANGED
|
@@ -21,11 +21,34 @@ export interface TrafficAggregate extends TrafficFlow {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const TYPES: Record<string, TrafficType> = {
|
|
24
|
-
auth: 'identity',
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
auth: 'identity',
|
|
25
|
+
session: 'identity',
|
|
26
|
+
users: 'identity',
|
|
27
|
+
profiles: 'identity',
|
|
28
|
+
accounts: 'identity',
|
|
29
|
+
ai: 'ai',
|
|
30
|
+
inference: 'ai',
|
|
31
|
+
models: 'ai',
|
|
32
|
+
chat: 'ai',
|
|
33
|
+
completions: 'ai',
|
|
34
|
+
stream: 'media',
|
|
35
|
+
tracks: 'media',
|
|
36
|
+
episodes: 'media',
|
|
37
|
+
recordings: 'media',
|
|
38
|
+
rooms: 'media',
|
|
39
|
+
messages: 'communication',
|
|
40
|
+
notifications: 'communication',
|
|
41
|
+
mail: 'communication',
|
|
42
|
+
media: 'media',
|
|
43
|
+
files: 'media',
|
|
44
|
+
assets: 'media',
|
|
45
|
+
upload: 'media',
|
|
46
|
+
uploads: 'media',
|
|
47
|
+
cdn: 'media',
|
|
48
|
+
storage: 'media',
|
|
49
|
+
images: 'media',
|
|
50
|
+
audio: 'media',
|
|
51
|
+
videos: 'media',
|
|
29
52
|
};
|
|
30
53
|
export function trafficType(path: string): TrafficType {
|
|
31
54
|
const parts = path.split('?')[0].split('/').filter(Boolean);
|
|
@@ -35,7 +58,10 @@ export function trafficType(path: string): TrafficType {
|
|
|
35
58
|
return TYPES[parts[0] ?? ''] ?? 'platform';
|
|
36
59
|
}
|
|
37
60
|
|
|
38
|
-
export function createTrafficCollector(
|
|
61
|
+
export function createTrafficCollector(
|
|
62
|
+
publish: (events: TrafficAggregate[]) => Promise<unknown>,
|
|
63
|
+
now = Date.now,
|
|
64
|
+
) {
|
|
39
65
|
const pending = new Map<string, { flow: TrafficFlow; requests: number; startedAt: number }>();
|
|
40
66
|
let flushing: Promise<void> | null = null;
|
|
41
67
|
return {
|
|
@@ -49,12 +75,22 @@ export function createTrafficCollector(publish: (events: TrafficAggregate[]) =>
|
|
|
49
75
|
while (flushing) await flushing;
|
|
50
76
|
if (pending.size === 0) return;
|
|
51
77
|
const emittedAt = new Date(now()).toISOString();
|
|
52
|
-
const events = [...pending.values()].map(({ flow, requests, startedAt }) => ({
|
|
78
|
+
const events = [...pending.values()].map(({ flow, requests, startedAt }) => ({
|
|
79
|
+
...flow,
|
|
80
|
+
requests,
|
|
81
|
+
windowStartedAt: new Date(startedAt).toISOString(),
|
|
82
|
+
emittedAt,
|
|
83
|
+
}));
|
|
53
84
|
pending.clear();
|
|
54
85
|
flushing = (async () => {
|
|
55
|
-
for (let offset = 0; offset < events.length; offset += 256)
|
|
86
|
+
for (let offset = 0; offset < events.length; offset += 256)
|
|
87
|
+
await publish(events.slice(offset, offset + 256));
|
|
56
88
|
})();
|
|
57
|
-
try {
|
|
89
|
+
try {
|
|
90
|
+
await flushing;
|
|
91
|
+
} finally {
|
|
92
|
+
flushing = null;
|
|
93
|
+
}
|
|
58
94
|
},
|
|
59
95
|
};
|
|
60
96
|
}
|
|
@@ -65,7 +101,10 @@ interface HttpRequest {
|
|
|
65
101
|
url?: string;
|
|
66
102
|
serviceApp?: { appName?: string };
|
|
67
103
|
}
|
|
68
|
-
interface HttpResponse {
|
|
104
|
+
interface HttpResponse {
|
|
105
|
+
once(event: string, listener: () => void): unknown;
|
|
106
|
+
setHeader?(name: string, value: string): unknown;
|
|
107
|
+
}
|
|
69
108
|
|
|
70
109
|
export function trafficMiddleware(
|
|
71
110
|
collector: Pick<ReturnType<typeof createTrafficCollector>, 'record'>,
|
|
@@ -76,24 +115,58 @@ export function trafficMiddleware(
|
|
|
76
115
|
response.setHeader?.('X-Oxy-Region', region);
|
|
77
116
|
const path = request.path ?? request.url ?? '/';
|
|
78
117
|
// Never report collection itself, probes or the dashboard transport.
|
|
79
|
-
if (
|
|
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
|
+
) {
|
|
126
|
+
next();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
80
129
|
const { edgePop } = metadataFromHeaders(request.headers);
|
|
81
130
|
const activityType = trafficType(path);
|
|
82
131
|
let finished = false;
|
|
83
132
|
const inboundFlow = (): TrafficFlow => {
|
|
84
133
|
// Read service identity AFTER the route's shared auth middleware resolved it.
|
|
85
|
-
const name = request.serviceApp?.appName
|
|
134
|
+
const name = request.serviceApp?.appName
|
|
135
|
+
?.toLowerCase()
|
|
136
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
137
|
+
.slice(0, 40);
|
|
86
138
|
const internal = Boolean(name);
|
|
87
139
|
const forwardedRegion = request.headers['x-oxy-source-region'];
|
|
88
|
-
const peerRegion = internal
|
|
89
|
-
|
|
140
|
+
const peerRegion = internal
|
|
141
|
+
? normalizeInfrastructureRegion(forwardedRegion)
|
|
142
|
+
: edgePop
|
|
143
|
+
? `edge-${edgePop}`
|
|
144
|
+
: undefined;
|
|
145
|
+
return {
|
|
146
|
+
region,
|
|
147
|
+
service,
|
|
148
|
+
activityType,
|
|
149
|
+
scope: internal ? 'internal' : 'external',
|
|
150
|
+
direction: 'inbound',
|
|
151
|
+
sourceRegion: peerRegion,
|
|
152
|
+
targetRegion: region,
|
|
153
|
+
sourceService: name,
|
|
154
|
+
targetService: service,
|
|
155
|
+
};
|
|
90
156
|
};
|
|
91
157
|
const finish = () => {
|
|
92
158
|
if (finished) return;
|
|
93
159
|
finished = true;
|
|
94
160
|
const flow = inboundFlow();
|
|
95
161
|
collector.record(flow);
|
|
96
|
-
collector.record({
|
|
162
|
+
collector.record({
|
|
163
|
+
...flow,
|
|
164
|
+
direction: 'outbound',
|
|
165
|
+
sourceRegion: region,
|
|
166
|
+
targetRegion: flow.sourceRegion,
|
|
167
|
+
sourceService: service,
|
|
168
|
+
targetService: flow.sourceService,
|
|
169
|
+
});
|
|
97
170
|
};
|
|
98
171
|
response.once('finish', finish);
|
|
99
172
|
// Failed/aborted requests are still real inbound activity. Do not invent a
|
|
@@ -107,7 +180,10 @@ export function trafficMiddleware(
|
|
|
107
180
|
};
|
|
108
181
|
}
|
|
109
182
|
|
|
110
|
-
export interface ServiceEndpoint {
|
|
183
|
+
export interface ServiceEndpoint {
|
|
184
|
+
service: string;
|
|
185
|
+
region: string;
|
|
186
|
+
}
|
|
111
187
|
export function instrumentTrafficFetch(
|
|
112
188
|
fetcher: typeof fetch,
|
|
113
189
|
collector: Pick<ReturnType<typeof createTrafficCollector>, 'record'>,
|
|
@@ -116,23 +192,51 @@ export function instrumentTrafficFetch(
|
|
|
116
192
|
resolveEndpoint: (url: URL) => ServiceEndpoint | undefined,
|
|
117
193
|
): typeof fetch {
|
|
118
194
|
return Object.assign(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
119
|
-
const url = new URL(
|
|
195
|
+
const url = new URL(
|
|
196
|
+
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url,
|
|
197
|
+
);
|
|
120
198
|
// Collection/auth control traffic cannot generate a feedback loop.
|
|
121
|
-
if (
|
|
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);
|
|
122
205
|
const peer = resolveEndpoint(url);
|
|
123
206
|
const flow: TrafficFlow = {
|
|
124
|
-
service,
|
|
125
|
-
|
|
126
|
-
|
|
207
|
+
service,
|
|
208
|
+
region,
|
|
209
|
+
scope: peer ? 'internal' : 'external',
|
|
210
|
+
direction: 'outbound',
|
|
211
|
+
activityType: trafficType(url.pathname),
|
|
212
|
+
sourceRegion: region,
|
|
213
|
+
targetRegion: peer?.region,
|
|
214
|
+
sourceService: service,
|
|
215
|
+
targetService: peer?.service,
|
|
127
216
|
};
|
|
128
|
-
const headers = new Headers(
|
|
217
|
+
const headers = new Headers(
|
|
218
|
+
init?.headers ?? (input instanceof Request ? input.headers : undefined),
|
|
219
|
+
);
|
|
129
220
|
if (peer) headers.set('X-Oxy-Source-Region', region);
|
|
130
221
|
let response: Response;
|
|
131
|
-
try {
|
|
132
|
-
|
|
133
|
-
|
|
222
|
+
try {
|
|
223
|
+
response = await fetcher(input, { ...init, headers });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
collector.record({ ...flow, targetRegion: undefined });
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
const targetRegion = peer
|
|
229
|
+
? normalizeInfrastructureRegion(response.headers.get('X-Oxy-Region') ?? undefined)
|
|
230
|
+
: undefined;
|
|
134
231
|
collector.record({ ...flow, targetRegion });
|
|
135
|
-
collector.record({
|
|
232
|
+
collector.record({
|
|
233
|
+
...flow,
|
|
234
|
+
direction: 'inbound',
|
|
235
|
+
sourceRegion: targetRegion,
|
|
236
|
+
targetRegion: region,
|
|
237
|
+
sourceService: peer?.service,
|
|
238
|
+
targetService: service,
|
|
239
|
+
});
|
|
136
240
|
return response;
|
|
137
241
|
}, fetcher) as typeof fetch;
|
|
138
242
|
}
|
|
@@ -140,14 +244,25 @@ export function instrumentTrafficFetch(
|
|
|
140
244
|
// Exact deployed API hosts; an arbitrary caller-supplied hostname cannot claim
|
|
141
245
|
// to be an internal service. Region is infrastructure metadata, never IP geo.
|
|
142
246
|
const OXY_API_SERVICES: Record<string, string> = {
|
|
143
|
-
'api.oxy.so': 'oxy-api',
|
|
144
|
-
'
|
|
145
|
-
'api.
|
|
146
|
-
'api.
|
|
147
|
-
'
|
|
148
|
-
'api.
|
|
149
|
-
'
|
|
150
|
-
'api.
|
|
247
|
+
'api.oxy.so': 'oxy-api',
|
|
248
|
+
'api.website.oxy.so': 'website',
|
|
249
|
+
'website-api.oxy.so': 'website',
|
|
250
|
+
'api.mention.earth': 'mention',
|
|
251
|
+
'api.mercaria.co': 'mercaria',
|
|
252
|
+
'api.alia.onl': 'alia',
|
|
253
|
+
'kaana.ai': 'kaana',
|
|
254
|
+
'api.homiio.com': 'homiio',
|
|
255
|
+
'api.syra.fm': 'syra',
|
|
256
|
+
'mention.earth': 'mention',
|
|
257
|
+
'mcp.mention.earth': 'mention-mcp',
|
|
258
|
+
'api.clarity.surf': 'clarity',
|
|
259
|
+
'api.peable.to': 'peable',
|
|
260
|
+
'api.crowdsource.oxy.so': 'crowdsource',
|
|
261
|
+
'api.moovo.now': 'moovo',
|
|
262
|
+
'api.allo.you': 'allo',
|
|
263
|
+
'api.noted.oxy.so': 'noted',
|
|
264
|
+
'api.schedio.app': 'schedio',
|
|
265
|
+
'api.tnp.network': 'tnp-api',
|
|
151
266
|
};
|
|
152
267
|
export function resolveOxyServiceEndpoint(url: URL): ServiceEndpoint | undefined {
|
|
153
268
|
const service = OXY_API_SERVICES[url.hostname];
|
|
@@ -185,7 +300,9 @@ const REGION_LOCATIONS: Record<string, { label: string; coordinates: [number, nu
|
|
|
185
300
|
'me-central-1': { label: 'UAE', coordinates: [54.37, 24.45] },
|
|
186
301
|
'il-central-1': { label: 'Tel Aviv', coordinates: [34.78, 32.09] },
|
|
187
302
|
};
|
|
188
|
-
export function infrastructureLocation(region: string) {
|
|
303
|
+
export function infrastructureLocation(region: string) {
|
|
304
|
+
return REGION_LOCATIONS[region];
|
|
305
|
+
}
|
|
189
306
|
|
|
190
307
|
export function normalizeInfrastructureRegion(value: unknown): string | undefined {
|
|
191
308
|
return typeof value === 'string' && /^[a-z]{2}(?:-[a-z]+)+-\d$/.test(value) ? value : undefined;
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
export function createEmfPublisher(options: EmfPublisherOptions): EmfPublisher {
|
|
186
|
+
const flushIntervalMs = options.flushIntervalMs ?? 60_000;
|
|
187
|
+
const maxSeries = options.maxSeries ?? 2_000;
|
|
188
|
+
const write = options.write ?? defaultWrite;
|
|
189
|
+
const now = options.now ?? Date.now;
|
|
190
|
+
let series = new Map<string, Series>();
|
|
191
|
+
|
|
192
|
+
const line = (entry: Series, timestamp: number): string => {
|
|
193
|
+
const { metric } = entry;
|
|
194
|
+
const dimensions = [
|
|
195
|
+
['Service'],
|
|
196
|
+
...(metric.dimensionSets ?? []).map((set) => ['Service', ...set]),
|
|
197
|
+
];
|
|
198
|
+
let value: unknown;
|
|
199
|
+
if (metric.kind === 'sum') value = entry.sum;
|
|
200
|
+
else if (metric.kind === 'gauge') value = entry.last;
|
|
201
|
+
else {
|
|
202
|
+
const { values, counts } = boundedDistribution(entry.buckets);
|
|
203
|
+
value = {
|
|
204
|
+
Values: values,
|
|
205
|
+
Counts: counts,
|
|
206
|
+
Max: entry.max,
|
|
207
|
+
Min: entry.min,
|
|
208
|
+
Count: entry.count,
|
|
209
|
+
Sum: entry.sum,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return JSON.stringify({
|
|
213
|
+
_aws: {
|
|
214
|
+
Timestamp: timestamp,
|
|
215
|
+
CloudWatchMetrics: [
|
|
216
|
+
{
|
|
217
|
+
Namespace: options.namespace,
|
|
218
|
+
Dimensions: dimensions,
|
|
219
|
+
Metrics: [{ Name: metric.name, Unit: metric.unit }],
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
...entry.labels,
|
|
224
|
+
Service: options.service,
|
|
225
|
+
[metric.name]: value,
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const flush = (): void => {
|
|
230
|
+
if (series.size === 0) return;
|
|
231
|
+
const current = series;
|
|
232
|
+
series = new Map();
|
|
233
|
+
const timestamp = now();
|
|
234
|
+
for (const entry of current.values()) {
|
|
235
|
+
try {
|
|
236
|
+
write(line(entry, timestamp));
|
|
237
|
+
} catch {
|
|
238
|
+
// Metrics are best effort; a failed write must never reach a request.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const timer = setInterval(flush, flushIntervalMs);
|
|
244
|
+
(timer as { unref?: () => void }).unref?.();
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
record(metric, value, labels) {
|
|
248
|
+
if (!Number.isFinite(value)) return;
|
|
249
|
+
if (metric.kind !== 'gauge' && value < 0) return;
|
|
250
|
+
let clean = cleanLabels(metric, labels);
|
|
251
|
+
let key = seriesKey(metric, clean);
|
|
252
|
+
if (!series.has(key) && series.size >= maxSeries) {
|
|
253
|
+
clean = Object.fromEntries(Object.keys(clean).map((name) => [name, 'other']));
|
|
254
|
+
key = seriesKey(metric, clean);
|
|
255
|
+
}
|
|
256
|
+
let entry = series.get(key);
|
|
257
|
+
if (!entry) {
|
|
258
|
+
entry = {
|
|
259
|
+
metric,
|
|
260
|
+
labels: clean,
|
|
261
|
+
sum: 0,
|
|
262
|
+
last: 0,
|
|
263
|
+
count: 0,
|
|
264
|
+
min: Number.POSITIVE_INFINITY,
|
|
265
|
+
max: Number.NEGATIVE_INFINITY,
|
|
266
|
+
buckets: new Map(),
|
|
267
|
+
};
|
|
268
|
+
series.set(key, entry);
|
|
269
|
+
}
|
|
270
|
+
entry.sum += value;
|
|
271
|
+
entry.last = value;
|
|
272
|
+
entry.count += 1;
|
|
273
|
+
entry.min = Math.min(entry.min, value);
|
|
274
|
+
entry.max = Math.max(entry.max, value);
|
|
275
|
+
if (metric.kind === 'distribution') {
|
|
276
|
+
const bucket = bucketOf(value);
|
|
277
|
+
entry.buckets.set(bucket, (entry.buckets.get(bucket) ?? 0) + 1);
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
flush,
|
|
281
|
+
stop() {
|
|
282
|
+
clearInterval(timer);
|
|
283
|
+
flush();
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -24,7 +24,9 @@ function firstHeaderValue(value: TelemetryHeaderValue): string | undefined {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function normalizeEdgePop(value: TelemetryHeaderValue): string | undefined {
|
|
27
|
-
return firstHeaderValue(value)
|
|
27
|
+
return firstHeaderValue(value)
|
|
28
|
+
?.match(/^[a-z]{3}$/i)?.[0]
|
|
29
|
+
?.toLowerCase();
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export function normalizeActivityId(value: TelemetryHeaderValue): string | undefined {
|
|
@@ -59,7 +61,11 @@ export function presenceBucket(observedAt: number, bucketMs = DEFAULT_PRESENCE_B
|
|
|
59
61
|
return Math.floor(observedAt / bucketMs);
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
export function presenceKeys(
|
|
64
|
+
export function presenceKeys(
|
|
65
|
+
sourceRegion: string,
|
|
66
|
+
observedAt: number,
|
|
67
|
+
bucketMs = DEFAULT_PRESENCE_BUCKET_MS,
|
|
68
|
+
): readonly [string, string] {
|
|
63
69
|
const current = presenceBucket(observedAt, bucketMs);
|
|
64
70
|
return [
|
|
65
71
|
`platform-activity:clients:${sourceRegion}:${current}`,
|