@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/socket.ts
CHANGED
|
@@ -13,11 +13,26 @@ export interface TrafficSocket {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const CATEGORIES: Record<string, TrafficType> = {
|
|
16
|
-
auth: 'identity',
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
auth: 'identity',
|
|
17
|
+
session: 'identity',
|
|
18
|
+
user: 'identity',
|
|
19
|
+
profile: 'identity',
|
|
20
|
+
chat: 'ai',
|
|
21
|
+
agent: 'ai',
|
|
22
|
+
inference: 'ai',
|
|
23
|
+
completion: 'ai',
|
|
24
|
+
message: 'communication',
|
|
25
|
+
notification: 'communication',
|
|
26
|
+
typing: 'communication',
|
|
27
|
+
media: 'media',
|
|
28
|
+
audio: 'media',
|
|
29
|
+
video: 'media',
|
|
30
|
+
stream: 'media',
|
|
31
|
+
player: 'media',
|
|
32
|
+
track: 'media',
|
|
33
|
+
playback: 'media',
|
|
34
|
+
playlist: 'media',
|
|
35
|
+
room: 'media',
|
|
21
36
|
};
|
|
22
37
|
|
|
23
38
|
/** Event names are reduced locally to five categories; payloads are never read. */
|
|
@@ -43,11 +58,19 @@ export function observeTrafficSocket(
|
|
|
43
58
|
const record = (event: string, direction: 'inbound' | 'outbound') => {
|
|
44
59
|
if (stopped) return;
|
|
45
60
|
const peer = options.getPeer?.();
|
|
46
|
-
const peerService =
|
|
61
|
+
const peerService =
|
|
62
|
+
peer && /^[a-z][a-z0-9-]{0,39}$/.test(peer.service) ? peer.service : undefined;
|
|
47
63
|
const edgePop = metadataFromHeaders(socket.handshake.headers).edgePop;
|
|
48
|
-
const peerRegion = peerService
|
|
64
|
+
const peerRegion = peerService
|
|
65
|
+
? normalizeInfrastructureRegion(peer?.region)
|
|
66
|
+
: edgePop
|
|
67
|
+
? `edge-${edgePop}`
|
|
68
|
+
: undefined;
|
|
49
69
|
collector.record({
|
|
50
|
-
region,
|
|
70
|
+
region,
|
|
71
|
+
service,
|
|
72
|
+
scope: peerService ? 'internal' : 'external',
|
|
73
|
+
direction,
|
|
51
74
|
activityType: socketTrafficType(event),
|
|
52
75
|
sourceRegion: direction === 'inbound' ? peerRegion : region,
|
|
53
76
|
targetRegion: direction === 'inbound' ? region : peerRegion,
|
|
@@ -74,6 +97,7 @@ export interface TrafficWebSocket {
|
|
|
74
97
|
readonly readyState: number;
|
|
75
98
|
// ws accepts data/options overloads; they are forwarded without inspecting
|
|
76
99
|
// or changing them. Keep this boundary permissive for Node ws versions.
|
|
100
|
+
// biome-ignore lint/suspicious/noExplicitAny: ws overloads are forwarded unchanged; this boundary stays permissive for Node ws versions
|
|
77
101
|
send(...args: any[]): unknown;
|
|
78
102
|
on(event: 'message', listener: () => void): unknown;
|
|
79
103
|
once(event: 'close', listener: () => void): unknown;
|
|
@@ -96,11 +120,19 @@ export function observeTrafficWebSocket(
|
|
|
96
120
|
const record = (direction: 'inbound' | 'outbound') => {
|
|
97
121
|
if (stopped) return;
|
|
98
122
|
const peer = options.getPeer?.();
|
|
99
|
-
const peerService =
|
|
123
|
+
const peerService =
|
|
124
|
+
peer && /^[a-z][a-z0-9-]{0,39}$/.test(peer.service) ? peer.service : undefined;
|
|
100
125
|
const edgePop = metadataFromHeaders(options.headers ?? {}).edgePop;
|
|
101
|
-
const peerRegion = peerService
|
|
126
|
+
const peerRegion = peerService
|
|
127
|
+
? normalizeInfrastructureRegion(peer?.region)
|
|
128
|
+
: edgePop
|
|
129
|
+
? `edge-${edgePop}`
|
|
130
|
+
: undefined;
|
|
102
131
|
collector.record({
|
|
103
|
-
service,
|
|
132
|
+
service,
|
|
133
|
+
region,
|
|
134
|
+
scope: peerService ? 'internal' : 'external',
|
|
135
|
+
direction,
|
|
104
136
|
activityType: options.activityType ?? 'communication',
|
|
105
137
|
sourceRegion: direction === 'inbound' ? peerRegion : region,
|
|
106
138
|
targetRegion: direction === 'inbound' ? region : peerRegion,
|
package/dist/cjs/edge.js
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.observeEdgeRequest = observeEdgeRequest;
|
|
4
|
-
const collector_js_1 = require("./collector.js");
|
|
5
|
-
const server_js_1 = require("./server.js");
|
|
6
|
-
// Share values only: Workers prohibit I/O promises/bodies crossing request contexts.
|
|
7
|
-
const tokens = new WeakMap();
|
|
8
|
-
const CONTROL = /^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|platform-infrastructure|infra-status|internal\/activity|auth\/service-token|cdn-cgi)(?:\/|$)/;
|
|
9
|
-
async function smallJson(response) {
|
|
10
|
-
if (!response.body)
|
|
11
|
-
throw new Error('Missing token response');
|
|
12
|
-
const reader = response.body.getReader();
|
|
13
|
-
const chunks = [];
|
|
14
|
-
let size = 0;
|
|
15
|
-
try {
|
|
16
|
-
for (;;) {
|
|
17
|
-
const { done, value } = await reader.read();
|
|
18
|
-
if (done)
|
|
19
|
-
break;
|
|
20
|
-
size += value.byteLength;
|
|
21
|
-
if (size > 16384) {
|
|
22
|
-
await reader.cancel();
|
|
23
|
-
throw new Error('Oversized token response');
|
|
24
|
-
}
|
|
25
|
-
chunks.push(value);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
finally {
|
|
29
|
-
reader.releaseLock();
|
|
30
|
-
}
|
|
31
|
-
const bytes = new Uint8Array(size);
|
|
32
|
-
let offset = 0;
|
|
33
|
-
for (const chunk of chunks) {
|
|
34
|
-
bytes.set(chunk, offset);
|
|
35
|
-
offset += chunk.byteLength;
|
|
36
|
-
}
|
|
37
|
-
return JSON.parse(new TextDecoder().decode(bytes));
|
|
38
|
-
}
|
|
39
|
-
async function publish(options, events) {
|
|
40
|
-
const { env } = options;
|
|
41
|
-
const base = new URL(env.OXY_EDGE_ACTIVITY_API_URL ?? 'https://api.oxy.so');
|
|
42
|
-
if (base.protocol !== 'https:' || base.username || base.password || base.pathname !== '/' || base.search || base.hash)
|
|
43
|
-
throw new Error('Invalid activity origin');
|
|
44
|
-
const fetcher = options.fetcher ?? fetch;
|
|
45
|
-
const abort = new AbortController();
|
|
46
|
-
const timeout = setTimeout(() => abort.abort(), 4000);
|
|
47
|
-
try {
|
|
48
|
-
let token;
|
|
49
|
-
if (options.credential)
|
|
50
|
-
token = await Promise.race([
|
|
51
|
-
options.credential(),
|
|
52
|
-
new Promise((_, reject) => abort.signal.addEventListener('abort', () => reject(new Error('Activity credential timeout')), { once: true })),
|
|
53
|
-
]);
|
|
54
|
-
else {
|
|
55
|
-
const key = env.OXY_EDGE_ACTIVITY_API_KEY;
|
|
56
|
-
const secret = env.OXY_EDGE_ACTIVITY_API_SECRET;
|
|
57
|
-
if (!key || !secret)
|
|
58
|
-
throw new Error('Missing edge activity credentials');
|
|
59
|
-
const cached = tokens.get(env);
|
|
60
|
-
if (cached && cached.key === key && cached.secret === secret && cached.origin === base.origin && cached.expiresAt > Date.now() + 60000)
|
|
61
|
-
token = cached.token;
|
|
62
|
-
else {
|
|
63
|
-
// Same exchange and expiry margin as OxyServices.getServiceToken().
|
|
64
|
-
const response = await fetcher(new URL('/auth/service-token', base), {
|
|
65
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
66
|
-
body: JSON.stringify({ apiKey: key, apiSecret: secret }), signal: abort.signal,
|
|
67
|
-
});
|
|
68
|
-
if (!response.ok) {
|
|
69
|
-
await response.body?.cancel();
|
|
70
|
-
throw new Error('Activity authentication failed');
|
|
71
|
-
}
|
|
72
|
-
const data = await smallJson(response);
|
|
73
|
-
if (typeof data.token !== 'string' || !data.token || typeof data.expiresIn !== 'number' || !Number.isFinite(data.expiresIn) || data.expiresIn <= 0)
|
|
74
|
-
throw new Error('Invalid activity token');
|
|
75
|
-
token = data.token;
|
|
76
|
-
tokens.set(env, { key, secret, origin: base.origin, token, expiresAt: Date.now() + data.expiresIn * 1000 });
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
const response = await fetcher(new URL('/internal/activity', base), {
|
|
80
|
-
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
81
|
-
body: JSON.stringify(events), signal: abort.signal,
|
|
82
|
-
});
|
|
83
|
-
if (response.status === 401)
|
|
84
|
-
tokens.delete(env);
|
|
85
|
-
await response.body?.cancel();
|
|
86
|
-
if (!response.ok)
|
|
87
|
-
throw new Error('Activity ingestion failed');
|
|
88
|
-
}
|
|
89
|
-
finally {
|
|
90
|
-
clearTimeout(timeout);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
/** Returns the original response/stream and preserves original handler errors. */
|
|
94
|
-
async function observeEdgeRequest(options) {
|
|
95
|
-
const report = () => { try {
|
|
96
|
-
(options.onError ?? console.error)('Edge activity publication failed');
|
|
97
|
-
}
|
|
98
|
-
catch { /* Never break serving. */ } };
|
|
99
|
-
const enabled = options.env.OXY_EDGE_ACTIVITY_ENABLED;
|
|
100
|
-
if (enabled === undefined || enabled === 'false')
|
|
101
|
-
return options.next();
|
|
102
|
-
if (enabled !== 'true' || !/^[a-z][a-z0-9-]{0,39}$/.test(options.service)) {
|
|
103
|
-
report();
|
|
104
|
-
return options.next();
|
|
105
|
-
}
|
|
106
|
-
const path = new URL(options.request.url).pathname;
|
|
107
|
-
if (CONTROL.test(path))
|
|
108
|
-
return options.next();
|
|
109
|
-
const pop = (0, server_js_1.normalizeEdgePop)(typeof options.request.cf?.colo === 'string' ? options.request.cf.colo : undefined);
|
|
110
|
-
const region = pop ? `edge-${pop}` : 'unknown';
|
|
111
|
-
const startedAt = new Date().toISOString();
|
|
112
|
-
const complete = (response) => {
|
|
113
|
-
const contentType = response?.headers.get('Content-Type') ?? '';
|
|
114
|
-
const activityType = /^(?:image|audio|video)\//i.test(contentType) ? 'media' : (0, collector_js_1.trafficType)(path);
|
|
115
|
-
const common = { service: options.service, region, scope: options.peer ? 'internal' : 'external', activityType, requests: 1, windowStartedAt: startedAt, emittedAt: new Date().toISOString() };
|
|
116
|
-
// The serving PoP is infrastructure metadata. Do not invent visitor location.
|
|
117
|
-
const events = [{ ...common, direction: 'inbound', sourceRegion: options.peer?.region, sourceService: options.peer?.service, targetRegion: region, targetService: options.service }];
|
|
118
|
-
if (response)
|
|
119
|
-
events.push({ ...common, direction: 'outbound', sourceRegion: region, sourceService: options.service, targetRegion: options.peer?.region, targetService: options.peer?.service });
|
|
120
|
-
try {
|
|
121
|
-
options.ctx.waitUntil(publish(options, events).catch(report));
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
report();
|
|
125
|
-
}
|
|
126
|
-
};
|
|
127
|
-
let response;
|
|
128
|
-
try {
|
|
129
|
-
response = await options.next();
|
|
130
|
-
}
|
|
131
|
-
catch (error) {
|
|
132
|
-
complete();
|
|
133
|
-
throw error;
|
|
134
|
-
}
|
|
135
|
-
complete(response);
|
|
136
|
-
return response;
|
|
137
|
-
}
|
package/dist/esm/edge.js
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import { trafficType } from './collector.js';
|
|
2
|
-
import { normalizeEdgePop } from './server.js';
|
|
3
|
-
// Share values only: Workers prohibit I/O promises/bodies crossing request contexts.
|
|
4
|
-
const tokens = new WeakMap();
|
|
5
|
-
const CONTROL = /^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|platform-infrastructure|infra-status|internal\/activity|auth\/service-token|cdn-cgi)(?:\/|$)/;
|
|
6
|
-
async function smallJson(response) {
|
|
7
|
-
if (!response.body)
|
|
8
|
-
throw new Error('Missing token response');
|
|
9
|
-
const reader = response.body.getReader();
|
|
10
|
-
const chunks = [];
|
|
11
|
-
let size = 0;
|
|
12
|
-
try {
|
|
13
|
-
for (;;) {
|
|
14
|
-
const { done, value } = await reader.read();
|
|
15
|
-
if (done)
|
|
16
|
-
break;
|
|
17
|
-
size += value.byteLength;
|
|
18
|
-
if (size > 16384) {
|
|
19
|
-
await reader.cancel();
|
|
20
|
-
throw new Error('Oversized token response');
|
|
21
|
-
}
|
|
22
|
-
chunks.push(value);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
finally {
|
|
26
|
-
reader.releaseLock();
|
|
27
|
-
}
|
|
28
|
-
const bytes = new Uint8Array(size);
|
|
29
|
-
let offset = 0;
|
|
30
|
-
for (const chunk of chunks) {
|
|
31
|
-
bytes.set(chunk, offset);
|
|
32
|
-
offset += chunk.byteLength;
|
|
33
|
-
}
|
|
34
|
-
return JSON.parse(new TextDecoder().decode(bytes));
|
|
35
|
-
}
|
|
36
|
-
async function publish(options, events) {
|
|
37
|
-
const { env } = options;
|
|
38
|
-
const base = new URL(env.OXY_EDGE_ACTIVITY_API_URL ?? 'https://api.oxy.so');
|
|
39
|
-
if (base.protocol !== 'https:' || base.username || base.password || base.pathname !== '/' || base.search || base.hash)
|
|
40
|
-
throw new Error('Invalid activity origin');
|
|
41
|
-
const fetcher = options.fetcher ?? fetch;
|
|
42
|
-
const abort = new AbortController();
|
|
43
|
-
const timeout = setTimeout(() => abort.abort(), 4000);
|
|
44
|
-
try {
|
|
45
|
-
let token;
|
|
46
|
-
if (options.credential)
|
|
47
|
-
token = await Promise.race([
|
|
48
|
-
options.credential(),
|
|
49
|
-
new Promise((_, reject) => abort.signal.addEventListener('abort', () => reject(new Error('Activity credential timeout')), { once: true })),
|
|
50
|
-
]);
|
|
51
|
-
else {
|
|
52
|
-
const key = env.OXY_EDGE_ACTIVITY_API_KEY;
|
|
53
|
-
const secret = env.OXY_EDGE_ACTIVITY_API_SECRET;
|
|
54
|
-
if (!key || !secret)
|
|
55
|
-
throw new Error('Missing edge activity credentials');
|
|
56
|
-
const cached = tokens.get(env);
|
|
57
|
-
if (cached && cached.key === key && cached.secret === secret && cached.origin === base.origin && cached.expiresAt > Date.now() + 60000)
|
|
58
|
-
token = cached.token;
|
|
59
|
-
else {
|
|
60
|
-
// Same exchange and expiry margin as OxyServices.getServiceToken().
|
|
61
|
-
const response = await fetcher(new URL('/auth/service-token', base), {
|
|
62
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
63
|
-
body: JSON.stringify({ apiKey: key, apiSecret: secret }), signal: abort.signal,
|
|
64
|
-
});
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
await response.body?.cancel();
|
|
67
|
-
throw new Error('Activity authentication failed');
|
|
68
|
-
}
|
|
69
|
-
const data = await smallJson(response);
|
|
70
|
-
if (typeof data.token !== 'string' || !data.token || typeof data.expiresIn !== 'number' || !Number.isFinite(data.expiresIn) || data.expiresIn <= 0)
|
|
71
|
-
throw new Error('Invalid activity token');
|
|
72
|
-
token = data.token;
|
|
73
|
-
tokens.set(env, { key, secret, origin: base.origin, token, expiresAt: Date.now() + data.expiresIn * 1000 });
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const response = await fetcher(new URL('/internal/activity', base), {
|
|
77
|
-
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
78
|
-
body: JSON.stringify(events), signal: abort.signal,
|
|
79
|
-
});
|
|
80
|
-
if (response.status === 401)
|
|
81
|
-
tokens.delete(env);
|
|
82
|
-
await response.body?.cancel();
|
|
83
|
-
if (!response.ok)
|
|
84
|
-
throw new Error('Activity ingestion failed');
|
|
85
|
-
}
|
|
86
|
-
finally {
|
|
87
|
-
clearTimeout(timeout);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
/** Returns the original response/stream and preserves original handler errors. */
|
|
91
|
-
export async function observeEdgeRequest(options) {
|
|
92
|
-
const report = () => { try {
|
|
93
|
-
(options.onError ?? console.error)('Edge activity publication failed');
|
|
94
|
-
}
|
|
95
|
-
catch { /* Never break serving. */ } };
|
|
96
|
-
const enabled = options.env.OXY_EDGE_ACTIVITY_ENABLED;
|
|
97
|
-
if (enabled === undefined || enabled === 'false')
|
|
98
|
-
return options.next();
|
|
99
|
-
if (enabled !== 'true' || !/^[a-z][a-z0-9-]{0,39}$/.test(options.service)) {
|
|
100
|
-
report();
|
|
101
|
-
return options.next();
|
|
102
|
-
}
|
|
103
|
-
const path = new URL(options.request.url).pathname;
|
|
104
|
-
if (CONTROL.test(path))
|
|
105
|
-
return options.next();
|
|
106
|
-
const pop = normalizeEdgePop(typeof options.request.cf?.colo === 'string' ? options.request.cf.colo : undefined);
|
|
107
|
-
const region = pop ? `edge-${pop}` : 'unknown';
|
|
108
|
-
const startedAt = new Date().toISOString();
|
|
109
|
-
const complete = (response) => {
|
|
110
|
-
const contentType = response?.headers.get('Content-Type') ?? '';
|
|
111
|
-
const activityType = /^(?:image|audio|video)\//i.test(contentType) ? 'media' : trafficType(path);
|
|
112
|
-
const common = { service: options.service, region, scope: options.peer ? 'internal' : 'external', activityType, requests: 1, windowStartedAt: startedAt, emittedAt: new Date().toISOString() };
|
|
113
|
-
// The serving PoP is infrastructure metadata. Do not invent visitor location.
|
|
114
|
-
const events = [{ ...common, direction: 'inbound', sourceRegion: options.peer?.region, sourceService: options.peer?.service, targetRegion: region, targetService: options.service }];
|
|
115
|
-
if (response)
|
|
116
|
-
events.push({ ...common, direction: 'outbound', sourceRegion: region, sourceService: options.service, targetRegion: options.peer?.region, targetService: options.peer?.service });
|
|
117
|
-
try {
|
|
118
|
-
options.ctx.waitUntil(publish(options, events).catch(report));
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
report();
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
let response;
|
|
125
|
-
try {
|
|
126
|
-
response = await options.next();
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
complete();
|
|
130
|
-
throw error;
|
|
131
|
-
}
|
|
132
|
-
complete(response);
|
|
133
|
-
return response;
|
|
134
|
-
}
|
package/dist/types/edge.d.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/** Server-only bindings; never expose them through frontend build variables. */
|
|
2
|
-
export interface EdgeActivityEnv {
|
|
3
|
-
OXY_EDGE_ACTIVITY_ENABLED?: string;
|
|
4
|
-
OXY_EDGE_ACTIVITY_API_KEY?: string;
|
|
5
|
-
OXY_EDGE_ACTIVITY_API_SECRET?: string;
|
|
6
|
-
OXY_EDGE_ACTIVITY_API_URL?: string;
|
|
7
|
-
}
|
|
8
|
-
export interface EdgeActivityContext {
|
|
9
|
-
waitUntil(promise: Promise<unknown>): void;
|
|
10
|
-
}
|
|
11
|
-
export interface EdgeActivityOptions {
|
|
12
|
-
service: string;
|
|
13
|
-
request: Request & {
|
|
14
|
-
cf?: {
|
|
15
|
-
colo?: unknown;
|
|
16
|
-
};
|
|
17
|
-
};
|
|
18
|
-
env: EdgeActivityEnv;
|
|
19
|
-
ctx: EdgeActivityContext;
|
|
20
|
-
next: () => Response | Promise<Response>;
|
|
21
|
-
credential?: () => Promise<string>;
|
|
22
|
-
fetcher?: typeof fetch;
|
|
23
|
-
/** Only supply after existing authentication verified this service identity. */
|
|
24
|
-
peer?: {
|
|
25
|
-
service: string;
|
|
26
|
-
region?: string;
|
|
27
|
-
};
|
|
28
|
-
/** Fixed message only: never credentials, URLs or upstream error bodies. */
|
|
29
|
-
onError?: (message: string) => void;
|
|
30
|
-
}
|
|
31
|
-
/** Returns the original response/stream and preserves original handler errors. */
|
|
32
|
-
export declare function observeEdgeRequest(options: EdgeActivityOptions): Promise<Response>;
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/** @jest-environment node */
|
|
2
|
-
import { observeEdgeRequest, type EdgeActivityEnv } from '../edge';
|
|
3
|
-
|
|
4
|
-
function setup(path = '/private-name.png?email=secret@example.com') {
|
|
5
|
-
const request = new Request(`https://example.com${path}`, { headers: { 'CF-Connecting-IP': '203.0.113.8', 'X-Oxy-Source-Region': 'eu-west-1', Authorization: 'private' } });
|
|
6
|
-
Object.assign(request, { cf: { colo: 'MAD', country: 'ES' } });
|
|
7
|
-
const tasks: Promise<unknown>[] = [];
|
|
8
|
-
const env: EdgeActivityEnv = { OXY_EDGE_ACTIVITY_ENABLED: 'true', OXY_EDGE_ACTIVITY_API_KEY: 'key', OXY_EDGE_ACTIVITY_API_SECRET: 'secret' };
|
|
9
|
-
const fetcher = jest.fn(async (input: RequestInfo | URL) => String(input).endsWith('service-token') ? Response.json({ token: 'token', expiresIn: 3600 }) : new Response(null, { status: 204 }));
|
|
10
|
-
return { request, env, tasks, fetcher, ctx: { waitUntil: (task: Promise<unknown>) => tasks.push(task) }, service: 'noted', onError: jest.fn() };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
test('preserves the response stream, cache headers and media type without personal metadata', async () => {
|
|
14
|
-
const state = setup();
|
|
15
|
-
const response = new Response(new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode('image')); c.close(); } }), { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public,max-age=3600' } });
|
|
16
|
-
expect(await observeEdgeRequest({ ...state, next: () => response })).toBe(response);
|
|
17
|
-
expect(response.bodyUsed).toBe(false);
|
|
18
|
-
await Promise.all(state.tasks);
|
|
19
|
-
const calls = state.fetcher.mock.calls as unknown as [URL, RequestInit][];
|
|
20
|
-
expect(JSON.parse(String(calls[0][1].body))).toEqual({ apiKey: 'key', apiSecret: 'secret' });
|
|
21
|
-
const events = JSON.parse(String(calls[1][1].body));
|
|
22
|
-
expect(events).toHaveLength(2);
|
|
23
|
-
expect(events[0]).toMatchObject({ region: 'edge-mad', scope: 'external', direction: 'inbound', activityType: 'media', targetRegion: 'edge-mad' });
|
|
24
|
-
expect(events[1]).toMatchObject({ direction: 'outbound', sourceRegion: 'edge-mad' });
|
|
25
|
-
expect(events[0].sourceRegion).toBeUndefined();
|
|
26
|
-
expect(JSON.stringify(events)).not.toMatch(/private|secret|203\.0|example|email|eu-west/);
|
|
27
|
-
expect(await response.text()).toBe('image');
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('preserves handler errors and only reports received activity', async () => {
|
|
31
|
-
const state = setup(); const error = new Error('upstream');
|
|
32
|
-
await expect(observeEdgeRequest({ ...state, next: () => { throw error; } })).rejects.toBe(error);
|
|
33
|
-
await Promise.all(state.tasks);
|
|
34
|
-
const calls = state.fetcher.mock.calls as unknown as [URL, RequestInit][];
|
|
35
|
-
expect(JSON.parse(String(calls[1][1].body)).map((x: {direction:string}) => x.direction)).toEqual(['inbound']);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test.each(['/health', '/api/ready', '/internal/activity', '/auth/service-token', '/cdn-cgi/trace'])('excludes control route %s', async (path) => {
|
|
39
|
-
const state = setup(path); const response = new Response(null);
|
|
40
|
-
expect(await observeEdgeRequest({ ...state, next: () => response })).toBe(response);
|
|
41
|
-
expect(state.tasks).toHaveLength(0);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('disabled bindings bypass publishing and missing enabled credentials report a fixed error', async () => {
|
|
45
|
-
const state = setup(); state.env = {};
|
|
46
|
-
await observeEdgeRequest({ ...state, next: () => new Response(null) });
|
|
47
|
-
expect(state.tasks).toHaveLength(0);
|
|
48
|
-
state.env.OXY_EDGE_ACTIVITY_ENABLED = 'true';
|
|
49
|
-
await observeEdgeRequest({ ...state, next: () => new Response(null) });
|
|
50
|
-
await Promise.all(state.tasks);
|
|
51
|
-
expect(state.onError).toHaveBeenCalledWith('Edge activity publication failed');
|
|
52
|
-
expect(state.fetcher).not.toHaveBeenCalled();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('verified peer is internal independently of inbound/outbound', async () => {
|
|
56
|
-
const state = setup();
|
|
57
|
-
await observeEdgeRequest({ ...state, peer: {service:'mention', region:'us-west-2'}, next: () => new Response(null) });
|
|
58
|
-
await Promise.all(state.tasks);
|
|
59
|
-
const calls = state.fetcher.mock.calls as unknown as [URL, RequestInit][];
|
|
60
|
-
expect(JSON.parse(String(calls[1][1].body))).toEqual([
|
|
61
|
-
expect.objectContaining({scope:'internal', direction:'inbound', sourceRegion:'us-west-2', sourceService:'mention'}),
|
|
62
|
-
expect.objectContaining({scope:'internal', direction:'outbound', targetRegion:'us-west-2', targetService:'mention'}),
|
|
63
|
-
]);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test('caches token values and invalidates on ingestion 401 without retrying a batch', async () => {
|
|
67
|
-
const state = setup();
|
|
68
|
-
const fetcher = jest.fn(async (input: RequestInfo | URL) => String(input).endsWith('service-token') ? Response.json({token:'token',expiresIn:3600}) : new Response(null, {status:401}));
|
|
69
|
-
for (let i=0;i<2;i++) { await observeEdgeRequest({...state, fetcher, next:()=>new Response(null)}); await Promise.all(state.tasks); }
|
|
70
|
-
expect(fetcher.mock.calls.map(([url])=>String(url))).toEqual(['https://api.oxy.so/auth/service-token','https://api.oxy.so/internal/activity','https://api.oxy.so/auth/service-token','https://api.oxy.so/internal/activity']);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test('unknown colo stays unknown even when caller spoofs geo headers', async () => {
|
|
74
|
-
const state = setup(); Object.assign(state.request, {cf:undefined});
|
|
75
|
-
await observeEdgeRequest({...state, credential:async()=>'injected', next:()=>new Response(null)}); await Promise.all(state.tasks);
|
|
76
|
-
const calls = state.fetcher.mock.calls as unknown as [URL, RequestInit][];
|
|
77
|
-
expect(calls).toHaveLength(1);
|
|
78
|
-
expect(JSON.parse(String(calls[0][1].body))[0].region).toBe('unknown');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('reuses only a token value on later requests and refreshes after credential rotation', async () => {
|
|
82
|
-
const state = setup();
|
|
83
|
-
for (let i=0;i<2;i++) { await observeEdgeRequest({...state,next:()=>new Response(null)}); await Promise.all(state.tasks); }
|
|
84
|
-
expect(state.fetcher.mock.calls).toHaveLength(3);
|
|
85
|
-
state.env.OXY_EDGE_ACTIVITY_API_SECRET = 'rotated';
|
|
86
|
-
await observeEdgeRequest({...state,next:()=>new Response(null)}); await Promise.all(state.tasks);
|
|
87
|
-
expect(state.fetcher.mock.calls).toHaveLength(5);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test('bounds an injected credential that never resolves without delaying the response', async () => {
|
|
91
|
-
jest.useFakeTimers();
|
|
92
|
-
try {
|
|
93
|
-
const state = setup(); const response = new Response(null);
|
|
94
|
-
expect(await observeEdgeRequest({...state,credential:()=>new Promise(()=>{}),next:()=>response})).toBe(response);
|
|
95
|
-
await jest.advanceTimersByTimeAsync(4_000);
|
|
96
|
-
await Promise.all(state.tasks);
|
|
97
|
-
expect(state.onError).toHaveBeenCalledTimes(1);
|
|
98
|
-
expect(state.fetcher).not.toHaveBeenCalled();
|
|
99
|
-
expect(jest.getTimerCount()).toBe(0);
|
|
100
|
-
} finally { jest.useRealTimers(); }
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test('bounds token response size and never publishes credentials in diagnostics', async () => {
|
|
104
|
-
const state = setup();
|
|
105
|
-
const fetcher = jest.fn(async()=>new Response('secret'.repeat(4000)));
|
|
106
|
-
await observeEdgeRequest({...state,fetcher,next:()=>new Response(null)});
|
|
107
|
-
await Promise.all(state.tasks);
|
|
108
|
-
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
109
|
-
expect(state.onError).toHaveBeenCalledWith('Edge activity publication failed');
|
|
110
|
-
});
|
package/src/edge.ts
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { trafficType, type TrafficAggregate } from './collector.js';
|
|
2
|
-
import { normalizeEdgePop } from './server.js';
|
|
3
|
-
|
|
4
|
-
/** Server-only bindings; never expose them through frontend build variables. */
|
|
5
|
-
export interface EdgeActivityEnv {
|
|
6
|
-
OXY_EDGE_ACTIVITY_ENABLED?: string;
|
|
7
|
-
OXY_EDGE_ACTIVITY_API_KEY?: string;
|
|
8
|
-
OXY_EDGE_ACTIVITY_API_SECRET?: string;
|
|
9
|
-
OXY_EDGE_ACTIVITY_API_URL?: string;
|
|
10
|
-
}
|
|
11
|
-
export interface EdgeActivityContext { waitUntil(promise: Promise<unknown>): void }
|
|
12
|
-
export interface EdgeActivityOptions {
|
|
13
|
-
service: string;
|
|
14
|
-
request: Request & { cf?: { colo?: unknown } };
|
|
15
|
-
env: EdgeActivityEnv;
|
|
16
|
-
ctx: EdgeActivityContext;
|
|
17
|
-
next: () => Response | Promise<Response>;
|
|
18
|
-
credential?: () => Promise<string>;
|
|
19
|
-
fetcher?: typeof fetch;
|
|
20
|
-
/** Only supply after existing authentication verified this service identity. */
|
|
21
|
-
peer?: { service: string; region?: string };
|
|
22
|
-
/** Fixed message only: never credentials, URLs or upstream error bodies. */
|
|
23
|
-
onError?: (message: string) => void;
|
|
24
|
-
}
|
|
25
|
-
interface CachedToken { key: string; secret: string; origin: string; token: string; expiresAt: number }
|
|
26
|
-
// Share values only: Workers prohibit I/O promises/bodies crossing request contexts.
|
|
27
|
-
const tokens = new WeakMap<EdgeActivityEnv, CachedToken>();
|
|
28
|
-
const CONTROL = /^\/(?:api\/)?(?:health|ready|live|platform-stats|platform-activity|platform-infrastructure|infra-status|internal\/activity|auth\/service-token|cdn-cgi)(?:\/|$)/;
|
|
29
|
-
|
|
30
|
-
async function smallJson(response: Response): Promise<unknown> {
|
|
31
|
-
if (!response.body) throw new Error('Missing token response');
|
|
32
|
-
const reader = response.body.getReader();
|
|
33
|
-
const chunks: Uint8Array[] = [];
|
|
34
|
-
let size = 0;
|
|
35
|
-
try {
|
|
36
|
-
for (;;) {
|
|
37
|
-
const { done, value } = await reader.read();
|
|
38
|
-
if (done) break;
|
|
39
|
-
size += value.byteLength;
|
|
40
|
-
if (size > 16_384) { await reader.cancel(); throw new Error('Oversized token response'); }
|
|
41
|
-
chunks.push(value);
|
|
42
|
-
}
|
|
43
|
-
} finally { reader.releaseLock(); }
|
|
44
|
-
const bytes = new Uint8Array(size);
|
|
45
|
-
let offset = 0;
|
|
46
|
-
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
47
|
-
return JSON.parse(new TextDecoder().decode(bytes));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function publish(options: EdgeActivityOptions, events: TrafficAggregate[]): Promise<void> {
|
|
51
|
-
const { env } = options;
|
|
52
|
-
const base = new URL(env.OXY_EDGE_ACTIVITY_API_URL ?? 'https://api.oxy.so');
|
|
53
|
-
if (base.protocol !== 'https:' || base.username || base.password || base.pathname !== '/' || base.search || base.hash) throw new Error('Invalid activity origin');
|
|
54
|
-
const fetcher = options.fetcher ?? fetch;
|
|
55
|
-
const abort = new AbortController();
|
|
56
|
-
const timeout = setTimeout(() => abort.abort(), 4_000);
|
|
57
|
-
try {
|
|
58
|
-
let token: string;
|
|
59
|
-
if (options.credential) token = await Promise.race([
|
|
60
|
-
options.credential(),
|
|
61
|
-
new Promise<never>((_, reject) => abort.signal.addEventListener('abort', () => reject(new Error('Activity credential timeout')), { once: true })),
|
|
62
|
-
]);
|
|
63
|
-
else {
|
|
64
|
-
const key = env.OXY_EDGE_ACTIVITY_API_KEY;
|
|
65
|
-
const secret = env.OXY_EDGE_ACTIVITY_API_SECRET;
|
|
66
|
-
if (!key || !secret) throw new Error('Missing edge activity credentials');
|
|
67
|
-
const cached = tokens.get(env);
|
|
68
|
-
if (cached && cached.key === key && cached.secret === secret && cached.origin === base.origin && cached.expiresAt > Date.now() + 60_000) token = cached.token;
|
|
69
|
-
else {
|
|
70
|
-
// Same exchange and expiry margin as OxyServices.getServiceToken().
|
|
71
|
-
const response = await fetcher(new URL('/auth/service-token', base), {
|
|
72
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
73
|
-
body: JSON.stringify({ apiKey: key, apiSecret: secret }), signal: abort.signal,
|
|
74
|
-
});
|
|
75
|
-
if (!response.ok) { await response.body?.cancel(); throw new Error('Activity authentication failed'); }
|
|
76
|
-
const data = await smallJson(response) as { token?: unknown; expiresIn?: unknown };
|
|
77
|
-
if (typeof data.token !== 'string' || !data.token || typeof data.expiresIn !== 'number' || !Number.isFinite(data.expiresIn) || data.expiresIn <= 0) throw new Error('Invalid activity token');
|
|
78
|
-
token = data.token;
|
|
79
|
-
tokens.set(env, { key, secret, origin: base.origin, token, expiresAt: Date.now() + data.expiresIn * 1_000 });
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
const response = await fetcher(new URL('/internal/activity', base), {
|
|
83
|
-
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
84
|
-
body: JSON.stringify(events), signal: abort.signal,
|
|
85
|
-
});
|
|
86
|
-
if (response.status === 401) tokens.delete(env);
|
|
87
|
-
await response.body?.cancel();
|
|
88
|
-
if (!response.ok) throw new Error('Activity ingestion failed');
|
|
89
|
-
} finally { clearTimeout(timeout); }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Returns the original response/stream and preserves original handler errors. */
|
|
93
|
-
export async function observeEdgeRequest(options: EdgeActivityOptions): Promise<Response> {
|
|
94
|
-
const report = () => { try { (options.onError ?? console.error)('Edge activity publication failed'); } catch { /* Never break serving. */ } };
|
|
95
|
-
const enabled = options.env.OXY_EDGE_ACTIVITY_ENABLED;
|
|
96
|
-
if (enabled === undefined || enabled === 'false') return options.next();
|
|
97
|
-
if (enabled !== 'true' || !/^[a-z][a-z0-9-]{0,39}$/.test(options.service)) { report(); return options.next(); }
|
|
98
|
-
const path = new URL(options.request.url).pathname;
|
|
99
|
-
if (CONTROL.test(path)) return options.next();
|
|
100
|
-
const pop = normalizeEdgePop(typeof options.request.cf?.colo === 'string' ? options.request.cf.colo : undefined);
|
|
101
|
-
const region = pop ? `edge-${pop}` : 'unknown';
|
|
102
|
-
const startedAt = new Date().toISOString();
|
|
103
|
-
const complete = (response?: Response) => {
|
|
104
|
-
const contentType = response?.headers.get('Content-Type') ?? '';
|
|
105
|
-
const activityType = /^(?:image|audio|video)\//i.test(contentType) ? 'media' : trafficType(path);
|
|
106
|
-
const common = { service: options.service, region, scope: options.peer ? 'internal' as const : 'external' as const, activityType, requests: 1, windowStartedAt: startedAt, emittedAt: new Date().toISOString() };
|
|
107
|
-
// The serving PoP is infrastructure metadata. Do not invent visitor location.
|
|
108
|
-
const events: TrafficAggregate[] = [{ ...common, direction: 'inbound', sourceRegion: options.peer?.region, sourceService: options.peer?.service, targetRegion: region, targetService: options.service }];
|
|
109
|
-
if (response) events.push({ ...common, direction: 'outbound', sourceRegion: region, sourceService: options.service, targetRegion: options.peer?.region, targetService: options.peer?.service });
|
|
110
|
-
try { options.ctx.waitUntil(publish(options, events).catch(report)); } catch { report(); }
|
|
111
|
-
};
|
|
112
|
-
let response: Response;
|
|
113
|
-
try { response = await options.next(); }
|
|
114
|
-
catch (error) { complete(); throw error; }
|
|
115
|
-
complete(response);
|
|
116
|
-
return response;
|
|
117
|
-
}
|