@oxy.so/telemetry 0.1.2 → 1.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 +36 -14
- package/dist/cjs/browser.js +8 -2
- package/dist/cjs/collector.js +180 -0
- package/dist/cjs/edge.js +137 -0
- package/dist/cjs/socket.js +102 -0
- package/dist/esm/browser.js +7 -1
- package/dist/esm/collector.js +171 -0
- package/dist/esm/edge.js +134 -0
- package/dist/esm/socket.js +97 -0
- package/dist/types/browser.d.ts +2 -0
- package/dist/types/collector.d.ts +50 -0
- package/dist/types/edge.d.ts +32 -0
- package/dist/types/socket.d.ts +50 -0
- package/package.json +47 -4
- package/src/__tests__/browser.test.ts +21 -0
- package/src/__tests__/collector.test.ts +95 -0
- package/src/__tests__/edge.test.ts +110 -0
- package/src/__tests__/socket.test.ts +118 -0
- package/src/browser.ts +8 -1
- package/src/collector.ts +192 -0
- package/src/edge.ts +117 -0
- package/src/socket.ts +131 -0
package/README.md
CHANGED
|
@@ -4,12 +4,11 @@ Framework-agnostic primitives for anonymous, real-time Oxy activity telemetry.
|
|
|
4
4
|
The package has no runtime dependencies and does not import Express, Socket.IO,
|
|
5
5
|
Redis, React, React Native or Expo.
|
|
6
6
|
|
|
7
|
-
Version: `0.1.2`.
|
|
8
|
-
|
|
9
7
|
## Entry points
|
|
10
8
|
|
|
11
9
|
- `@oxy.so/telemetry` — shared header names and aggregate interfaces.
|
|
12
10
|
- `@oxy.so/telemetry/browser` — ephemeral browser metadata.
|
|
11
|
+
- `@oxy.so/telemetry/collector` — bounded HTTP/custom-transport flow aggregation.
|
|
13
12
|
- `@oxy.so/telemetry/server` — pure validation, route bucketing and cardinality
|
|
14
13
|
keys/interfaces.
|
|
15
14
|
|
|
@@ -33,7 +32,7 @@ session storage, IndexedDB or a URL. Closing or reloading the page loses it.
|
|
|
33
32
|
`X-Oxy-Edge-Region` contains only the three-letter Cloudflare PoP code obtained
|
|
34
33
|
from the same-origin `/cdn-cgi/trace` endpoint. The helper reads no IP field and
|
|
35
34
|
returns, logs and persists none of the rest of the trace response. Discovery is
|
|
36
|
-
cached per runtime, has a one-second timeout and is disabled on loopback.
|
|
35
|
+
cached for 15 seconds per runtime (including failed discovery), has a one-second timeout and is disabled on loopback.
|
|
37
36
|
|
|
38
37
|
Use `createBrowserTelemetry()` to create an isolated runtime or inject clock,
|
|
39
38
|
crypto, location and trace-fetch dependencies in tests.
|
|
@@ -81,10 +80,10 @@ an IP, user/account/session IDs, request bodies, query strings and raw paths.
|
|
|
81
80
|
The opaque activity ID exists only to estimate short-window cardinality and must
|
|
82
81
|
not be persisted as an event log or joined to product identity.
|
|
83
82
|
|
|
84
|
-
## Migration from `@
|
|
83
|
+
## Migration from `@oxy.so/*`
|
|
85
84
|
|
|
86
85
|
The first consumers will replace browser helpers currently owned privately by
|
|
87
|
-
`@
|
|
86
|
+
`@oxy.so/core` with imports from `@oxy.so/telemetry/browser`. API collectors will
|
|
88
87
|
replace their local validation and bucketing logic with
|
|
89
88
|
`@oxy.so/telemetry/server`. This package deliberately provides no compatibility
|
|
90
89
|
re-export: consumers move imports directly to the new owner, then the old
|
|
@@ -95,21 +94,44 @@ exports while applications migrate their direct imports.
|
|
|
95
94
|
|
|
96
95
|
## Collector, realtime and reconnect
|
|
97
96
|
|
|
98
|
-
The
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
The collector records successful, failed and aborted HTTP requests. A completed
|
|
98
|
+
response is a separate observed outbound flow; an aborted request never produces
|
|
99
|
+
a fabricated response. Scope (`internal`/`external`), direction relative to the
|
|
100
|
+
reporter (`inbound`/`outbound`) and type (`identity`, `ai`, `communication`,
|
|
101
|
+
`media`, `platform`) are independent dimensions. Source and destination always
|
|
102
|
+
follow the actual movement, even when two services share one region.
|
|
103
|
+
|
|
104
|
+
`trafficMiddleware` attaches before routes and reads the verified service
|
|
105
|
+
identity after route authentication. `instrumentTrafficFetch` observes actual
|
|
106
|
+
outbound calls; internal destination regions come from the peer's `X-Oxy-Region`
|
|
107
|
+
response, not a guessed IP location. Unlocated peers remain unlocated. Health,
|
|
108
|
+
telemetry collection, infrastructure snapshots and service-token minting are
|
|
109
|
+
excluded from observation where applicable to prevent control traffic feedback.
|
|
110
|
+
Custom transports can call the collector's `record` method explicitly.
|
|
111
|
+
|
|
112
|
+
Each collector retains at most 512 distinct pending flows and publishes chunks
|
|
113
|
+
of at most 256. Publishing is best effort: a failed batch is discarded, with no
|
|
114
|
+
durable retry queue. These are live operational observations, not billing or
|
|
115
|
+
lossless historical request totals. No dashboard viewer is needed to collect.
|
|
102
116
|
|
|
103
117
|
Realtime transport publishes aggregate buckets, not individual metadata. A
|
|
104
118
|
socket event may carry `ActivityAggregate`: request count, approximate active
|
|
105
119
|
clients, source/target regions, service and window timestamps. Transport and
|
|
106
120
|
fan-out are application concerns and intentionally absent from this package.
|
|
107
121
|
|
|
108
|
-
Realtime events are not a durable replay log.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
122
|
+
Realtime activity events are not a durable replay log. The dashboard retains a
|
|
123
|
+
60-second window of distinct flows and resumes live events on reconnect.
|
|
124
|
+
Infrastructure is separate: the API broadcasts a complete snapshot on connection
|
|
125
|
+
and on changes. Registration/removal updates immediately; heartbeats run every
|
|
126
|
+
10 seconds and crashed instances expire after 45 seconds (observed at the next
|
|
127
|
+
registry refresh). Only reporting instances are included. The publisher must be
|
|
128
|
+
installed in each service before this can represent the whole ecosystem.
|
|
129
|
+
|
|
130
|
+
The authenticated publisher is `createEcosystemTraffic` from `@oxy.so/core/server`.
|
|
131
|
+
It exposes HTTP middleware, `wrapFetch`, `record` for explicit transport events,
|
|
132
|
+
and `stop` for graceful removal. It requires an existing trusted service
|
|
133
|
+
credential and a known infrastructure region (or explicit infrastructure
|
|
134
|
+
coordinates). Credentials, user metadata and paths never enter public snapshots.
|
|
113
135
|
|
|
114
136
|
## Development
|
|
115
137
|
|
package/dist/cjs/browser.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getBrowserTelemetryHeaders = exports.getBrowserEdgeRegionHeader = exports.getBrowserActivityIdHeader = exports.DEFAULT_EDGE_TRACE_TIMEOUT_MS = exports.DEFAULT_ACTIVITY_ID_ROTATION_MS = void 0;
|
|
3
|
+
exports.getBrowserTelemetryHeaders = exports.getBrowserEdgeRegionHeader = exports.getBrowserActivityIdHeader = exports.DEFAULT_EDGE_CACHE_MS = exports.DEFAULT_EDGE_TRACE_TIMEOUT_MS = exports.DEFAULT_ACTIVITY_ID_ROTATION_MS = void 0;
|
|
4
4
|
exports.createBrowserTelemetry = createBrowserTelemetry;
|
|
5
5
|
const index_js_1 = require("./index.js");
|
|
6
6
|
exports.DEFAULT_ACTIVITY_ID_ROTATION_MS = 5 * 60 * 1000;
|
|
7
7
|
exports.DEFAULT_EDGE_TRACE_TIMEOUT_MS = 1000;
|
|
8
|
+
exports.DEFAULT_EDGE_CACHE_MS = 15000;
|
|
8
9
|
function createUuid(cryptoApi) {
|
|
9
10
|
if (!cryptoApi)
|
|
10
11
|
return null;
|
|
@@ -27,6 +28,7 @@ function createBrowserTelemetry(options = {}) {
|
|
|
27
28
|
let activityId = null;
|
|
28
29
|
let activityIdCreatedAt = 0;
|
|
29
30
|
let edgePopPromise = null;
|
|
31
|
+
let edgePopReadAt = -Infinity;
|
|
30
32
|
const getActivityIdHeader = () => {
|
|
31
33
|
const hasInjectedBrowser = Boolean(options.location);
|
|
32
34
|
const hasBrowserRuntime = typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
@@ -64,7 +66,11 @@ function createBrowserTelemetry(options = {}) {
|
|
|
64
66
|
}
|
|
65
67
|
};
|
|
66
68
|
const getEdgeRegionHeader = async () => {
|
|
67
|
-
|
|
69
|
+
const now = (options.now ?? Date.now)();
|
|
70
|
+
if (!edgePopPromise || now < edgePopReadAt || now - edgePopReadAt >= (options.edgeCacheMs ?? exports.DEFAULT_EDGE_CACHE_MS)) {
|
|
71
|
+
edgePopReadAt = now;
|
|
72
|
+
edgePopPromise = discoverEdgePop();
|
|
73
|
+
}
|
|
68
74
|
const edgePop = await edgePopPromise;
|
|
69
75
|
return edgePop ? { [index_js_1.OXY_EDGE_REGION_HEADER]: edgePop } : {};
|
|
70
76
|
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.trafficType = trafficType;
|
|
4
|
+
exports.createTrafficCollector = createTrafficCollector;
|
|
5
|
+
exports.trafficMiddleware = trafficMiddleware;
|
|
6
|
+
exports.instrumentTrafficFetch = instrumentTrafficFetch;
|
|
7
|
+
exports.resolveOxyServiceEndpoint = resolveOxyServiceEndpoint;
|
|
8
|
+
exports.infrastructureLocation = infrastructureLocation;
|
|
9
|
+
exports.normalizeInfrastructureRegion = normalizeInfrastructureRegion;
|
|
10
|
+
const server_js_1 = require("./server.js");
|
|
11
|
+
const TYPES = {
|
|
12
|
+
auth: 'identity', session: 'identity', users: 'identity', profiles: 'identity', accounts: 'identity',
|
|
13
|
+
ai: 'ai', inference: 'ai', models: 'ai', chat: 'ai', completions: 'ai',
|
|
14
|
+
stream: 'media', tracks: 'media', episodes: 'media', recordings: 'media', rooms: 'media',
|
|
15
|
+
messages: 'communication', notifications: 'communication', mail: 'communication',
|
|
16
|
+
media: 'media', files: 'media', assets: 'media', upload: 'media', uploads: 'media', cdn: 'media', storage: 'media', images: 'media', audio: 'media', videos: 'media',
|
|
17
|
+
};
|
|
18
|
+
function trafficType(path) {
|
|
19
|
+
const parts = path.split('?')[0].split('/').filter(Boolean);
|
|
20
|
+
if (parts[0] === 'alia')
|
|
21
|
+
parts.shift();
|
|
22
|
+
if (parts[0] === 'api')
|
|
23
|
+
parts.shift();
|
|
24
|
+
if (/^v\d+$/.test(parts[0] ?? ''))
|
|
25
|
+
parts.shift();
|
|
26
|
+
return TYPES[parts[0] ?? ''] ?? 'platform';
|
|
27
|
+
}
|
|
28
|
+
function createTrafficCollector(publish, now = Date.now) {
|
|
29
|
+
const pending = new Map();
|
|
30
|
+
let flushing = null;
|
|
31
|
+
return {
|
|
32
|
+
record(flow) {
|
|
33
|
+
const key = JSON.stringify(flow);
|
|
34
|
+
const existing = pending.get(key);
|
|
35
|
+
if (existing)
|
|
36
|
+
existing.requests++;
|
|
37
|
+
else if (pending.size < 512)
|
|
38
|
+
pending.set(key, { flow, requests: 1, startedAt: now() });
|
|
39
|
+
},
|
|
40
|
+
async flush() {
|
|
41
|
+
while (flushing)
|
|
42
|
+
await flushing;
|
|
43
|
+
if (pending.size === 0)
|
|
44
|
+
return;
|
|
45
|
+
const emittedAt = new Date(now()).toISOString();
|
|
46
|
+
const events = [...pending.values()].map(({ flow, requests, startedAt }) => ({ ...flow, requests, windowStartedAt: new Date(startedAt).toISOString(), emittedAt }));
|
|
47
|
+
pending.clear();
|
|
48
|
+
flushing = (async () => {
|
|
49
|
+
for (let offset = 0; offset < events.length; offset += 256)
|
|
50
|
+
await publish(events.slice(offset, offset + 256));
|
|
51
|
+
})();
|
|
52
|
+
try {
|
|
53
|
+
await flushing;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
flushing = null;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function trafficMiddleware(collector, service, region) {
|
|
62
|
+
return (request, response, next) => {
|
|
63
|
+
response.setHeader?.('X-Oxy-Region', region);
|
|
64
|
+
const path = request.path ?? request.url ?? '/';
|
|
65
|
+
// 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) || path.startsWith('/internal/activity') || path.startsWith('/auth/service-token') || path.startsWith('/cdn-cgi/')) {
|
|
67
|
+
next();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const { edgePop } = (0, server_js_1.metadataFromHeaders)(request.headers);
|
|
71
|
+
const activityType = trafficType(path);
|
|
72
|
+
let finished = false;
|
|
73
|
+
const inboundFlow = () => {
|
|
74
|
+
// Read service identity AFTER the route's shared auth middleware resolved it.
|
|
75
|
+
const name = request.serviceApp?.appName?.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 40);
|
|
76
|
+
const internal = Boolean(name);
|
|
77
|
+
const forwardedRegion = request.headers['x-oxy-source-region'];
|
|
78
|
+
const peerRegion = internal ? normalizeInfrastructureRegion(forwardedRegion) : edgePop ? `edge-${edgePop}` : undefined;
|
|
79
|
+
return { region, service, activityType, scope: internal ? 'internal' : 'external', direction: 'inbound', sourceRegion: peerRegion, targetRegion: region, sourceService: name, targetService: service };
|
|
80
|
+
};
|
|
81
|
+
const finish = () => {
|
|
82
|
+
if (finished)
|
|
83
|
+
return;
|
|
84
|
+
finished = true;
|
|
85
|
+
const flow = inboundFlow();
|
|
86
|
+
collector.record(flow);
|
|
87
|
+
collector.record({ ...flow, direction: 'outbound', sourceRegion: region, targetRegion: flow.sourceRegion, sourceService: service, targetService: flow.sourceService });
|
|
88
|
+
};
|
|
89
|
+
response.once('finish', finish);
|
|
90
|
+
// Failed/aborted requests are still real inbound activity. Do not invent a
|
|
91
|
+
// completed outbound response when the connection closes prematurely.
|
|
92
|
+
response.once('close', () => {
|
|
93
|
+
if (finished)
|
|
94
|
+
return;
|
|
95
|
+
finished = true;
|
|
96
|
+
collector.record(inboundFlow());
|
|
97
|
+
});
|
|
98
|
+
next();
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function instrumentTrafficFetch(fetcher, collector, service, region, resolveEndpoint) {
|
|
102
|
+
return Object.assign(async (input, init) => {
|
|
103
|
+
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
|
|
104
|
+
// Collection/auth control traffic cannot generate a feedback loop.
|
|
105
|
+
if (url.pathname.startsWith('/internal/activity') || url.pathname.startsWith('/auth/service-token') || url.pathname === '/platform-infrastructure')
|
|
106
|
+
return fetcher(input, init);
|
|
107
|
+
const peer = resolveEndpoint(url);
|
|
108
|
+
const flow = {
|
|
109
|
+
service, region, scope: peer ? 'internal' : 'external', direction: 'outbound',
|
|
110
|
+
activityType: trafficType(url.pathname), sourceRegion: region, targetRegion: peer?.region,
|
|
111
|
+
sourceService: service, targetService: peer?.service,
|
|
112
|
+
};
|
|
113
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
114
|
+
if (peer)
|
|
115
|
+
headers.set('X-Oxy-Source-Region', region);
|
|
116
|
+
let response;
|
|
117
|
+
try {
|
|
118
|
+
response = await fetcher(input, { ...init, headers });
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
collector.record({ ...flow, targetRegion: undefined });
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
const targetRegion = peer ? normalizeInfrastructureRegion(response.headers.get('X-Oxy-Region') ?? undefined) : undefined;
|
|
125
|
+
collector.record({ ...flow, targetRegion });
|
|
126
|
+
collector.record({ ...flow, direction: 'inbound', sourceRegion: targetRegion, targetRegion: region, sourceService: peer?.service, targetService: service });
|
|
127
|
+
return response;
|
|
128
|
+
}, fetcher);
|
|
129
|
+
}
|
|
130
|
+
// Exact deployed API hosts; an arbitrary caller-supplied hostname cannot claim
|
|
131
|
+
// to be an internal service. Region is infrastructure metadata, never IP geo.
|
|
132
|
+
const OXY_API_SERVICES = {
|
|
133
|
+
'api.oxy.so': 'oxy-api', 'api.website.oxy.so': 'website',
|
|
134
|
+
'website-api.oxy.so': 'website', 'api.mention.earth': 'mention',
|
|
135
|
+
'api.mercaria.co': 'mercaria', 'api.alia.onl': 'alia', 'kaana.ai': 'kaana',
|
|
136
|
+
'api.homiio.com': 'homiio', 'api.syra.fm': 'syra',
|
|
137
|
+
'mention.earth': 'mention', 'mcp.mention.earth': 'mention-mcp',
|
|
138
|
+
'api.clarity.surf': 'clarity', 'api.peable.to': 'peable',
|
|
139
|
+
'api.crowdsource.oxy.so': 'crowdsource', 'api.moovo.now': 'moovo',
|
|
140
|
+
'api.allo.you': 'allo', 'api.noted.oxy.so': 'noted', 'api.schedio.app': 'schedio', 'api.tnp.network': 'tnp-api',
|
|
141
|
+
};
|
|
142
|
+
function resolveOxyServiceEndpoint(url) {
|
|
143
|
+
const service = OXY_API_SERVICES[url.hostname];
|
|
144
|
+
return url.protocol === 'https:' && service ? { service, region: 'us-west-2' } : undefined;
|
|
145
|
+
}
|
|
146
|
+
const REGION_LOCATIONS = {
|
|
147
|
+
'us-west-2': { label: 'Oregon', coordinates: [-122.6765, 45.5231] },
|
|
148
|
+
'us-west-1': { label: 'Northern California', coordinates: [-121.89, 37.34] },
|
|
149
|
+
'us-east-1': { label: 'Northern Virginia', coordinates: [-77.49, 39.04] },
|
|
150
|
+
'us-east-2': { label: 'Ohio', coordinates: [-82.99, 39.96] },
|
|
151
|
+
'eu-west-1': { label: 'Ireland', coordinates: [-6.26, 53.35] },
|
|
152
|
+
'eu-west-2': { label: 'London', coordinates: [-0.1276, 51.5072] },
|
|
153
|
+
'eu-west-3': { label: 'Paris', coordinates: [2.3522, 48.8566] },
|
|
154
|
+
'eu-central-1': { label: 'Frankfurt', coordinates: [8.6821, 50.1109] },
|
|
155
|
+
'eu-central-2': { label: 'Zurich', coordinates: [8.54, 47.38] },
|
|
156
|
+
'eu-north-1': { label: 'Stockholm', coordinates: [18.07, 59.33] },
|
|
157
|
+
'eu-south-1': { label: 'Milan', coordinates: [9.19, 45.46] },
|
|
158
|
+
'eu-south-2': { label: 'Spain', coordinates: [-0.89, 41.65] },
|
|
159
|
+
'ap-northeast-1': { label: 'Tokyo', coordinates: [139.69, 35.69] },
|
|
160
|
+
'ap-northeast-2': { label: 'Seoul', coordinates: [126.98, 37.57] },
|
|
161
|
+
'ap-northeast-3': { label: 'Osaka', coordinates: [135.5, 34.69] },
|
|
162
|
+
'ap-southeast-1': { label: 'Singapore', coordinates: [103.82, 1.35] },
|
|
163
|
+
'ap-southeast-2': { label: 'Sydney', coordinates: [151.21, -33.87] },
|
|
164
|
+
'ap-southeast-3': { label: 'Jakarta', coordinates: [106.85, -6.21] },
|
|
165
|
+
'ap-southeast-4': { label: 'Melbourne', coordinates: [144.96, -37.81] },
|
|
166
|
+
'ap-south-1': { label: 'Mumbai', coordinates: [72.88, 19.08] },
|
|
167
|
+
'ap-south-2': { label: 'Hyderabad', coordinates: [78.49, 17.38] },
|
|
168
|
+
'ap-east-1': { label: 'Hong Kong', coordinates: [114.17, 22.32] },
|
|
169
|
+
'ca-central-1': { label: 'Canada', coordinates: [-73.57, 45.5] },
|
|
170
|
+
'ca-west-1': { label: 'Calgary', coordinates: [-114.07, 51.05] },
|
|
171
|
+
'sa-east-1': { label: 'São Paulo', coordinates: [-46.63, -23.55] },
|
|
172
|
+
'af-south-1': { label: 'Cape Town', coordinates: [18.42, -33.92] },
|
|
173
|
+
'me-south-1': { label: 'Bahrain', coordinates: [50.59, 26.22] },
|
|
174
|
+
'me-central-1': { label: 'UAE', coordinates: [54.37, 24.45] },
|
|
175
|
+
'il-central-1': { label: 'Tel Aviv', coordinates: [34.78, 32.09] },
|
|
176
|
+
};
|
|
177
|
+
function infrastructureLocation(region) { return REGION_LOCATIONS[region]; }
|
|
178
|
+
function normalizeInfrastructureRegion(value) {
|
|
179
|
+
return typeof value === 'string' && /^[a-z]{2}(?:-[a-z]+)+-\d$/.test(value) ? value : undefined;
|
|
180
|
+
}
|
package/dist/cjs/edge.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.socketTrafficType = socketTrafficType;
|
|
4
|
+
exports.observeTrafficSocket = observeTrafficSocket;
|
|
5
|
+
exports.observeTrafficWebSocket = observeTrafficWebSocket;
|
|
6
|
+
const collector_js_1 = require("./collector.js");
|
|
7
|
+
const server_js_1 = require("./server.js");
|
|
8
|
+
const CATEGORIES = {
|
|
9
|
+
auth: 'identity', session: 'identity', user: 'identity', profile: 'identity',
|
|
10
|
+
chat: 'ai', agent: 'ai', inference: 'ai', completion: 'ai',
|
|
11
|
+
message: 'communication', notification: 'communication', typing: 'communication',
|
|
12
|
+
media: 'media', audio: 'media', video: 'media', stream: 'media', player: 'media',
|
|
13
|
+
track: 'media', playback: 'media', playlist: 'media', room: 'media',
|
|
14
|
+
};
|
|
15
|
+
/** Event names are reduced locally to five categories; payloads are never read. */
|
|
16
|
+
function socketTrafficType(event) {
|
|
17
|
+
return CATEGORIES[event.toLowerCase().split(/[:/._-]/, 1)[0]] ?? 'platform';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Observes application events only, once per local socket. Socket.IO catch-all
|
|
21
|
+
* hooks exclude acknowledgements, ping/pong and transport frames. Outgoing
|
|
22
|
+
* packets describe sends, not delivery acknowledgements. No connection ID,
|
|
23
|
+
* event name, IP, credential, room name or payload enters a traffic record.
|
|
24
|
+
*/
|
|
25
|
+
function observeTrafficSocket(socket, collector, service, region, options = {}) {
|
|
26
|
+
if (/^\/platform-(?:activity|stats)(?:\/|$)/.test(socket.nsp?.name ?? ''))
|
|
27
|
+
return () => { };
|
|
28
|
+
let stopped = false;
|
|
29
|
+
const record = (event, direction) => {
|
|
30
|
+
if (stopped)
|
|
31
|
+
return;
|
|
32
|
+
const peer = options.getPeer?.();
|
|
33
|
+
const peerService = peer && /^[a-z][a-z0-9-]{0,39}$/.test(peer.service) ? peer.service : undefined;
|
|
34
|
+
const edgePop = (0, server_js_1.metadataFromHeaders)(socket.handshake.headers).edgePop;
|
|
35
|
+
const peerRegion = peerService ? (0, collector_js_1.normalizeInfrastructureRegion)(peer?.region) : edgePop ? `edge-${edgePop}` : undefined;
|
|
36
|
+
collector.record({
|
|
37
|
+
region, service, scope: peerService ? 'internal' : 'external', direction,
|
|
38
|
+
activityType: socketTrafficType(event),
|
|
39
|
+
sourceRegion: direction === 'inbound' ? peerRegion : region,
|
|
40
|
+
targetRegion: direction === 'inbound' ? region : peerRegion,
|
|
41
|
+
sourceService: direction === 'inbound' ? peerService : service,
|
|
42
|
+
targetService: direction === 'inbound' ? service : peerService,
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const inbound = (event) => record(event, 'inbound');
|
|
46
|
+
const outbound = (event) => record(event, 'outbound');
|
|
47
|
+
const stop = () => {
|
|
48
|
+
if (stopped)
|
|
49
|
+
return;
|
|
50
|
+
stopped = true;
|
|
51
|
+
socket.offAny(inbound);
|
|
52
|
+
socket.offAnyOutgoing(outbound);
|
|
53
|
+
socket.off('disconnect', stop);
|
|
54
|
+
};
|
|
55
|
+
socket.onAny(inbound);
|
|
56
|
+
socket.onAnyOutgoing(outbound);
|
|
57
|
+
socket.once('disconnect', stop);
|
|
58
|
+
return stop;
|
|
59
|
+
}
|
|
60
|
+
/** Raw ws application messages. Sends are attempts, never delivery receipts. */
|
|
61
|
+
function observeTrafficWebSocket(socket, collector, service, region, options = {}) {
|
|
62
|
+
let stopped = false;
|
|
63
|
+
const record = (direction) => {
|
|
64
|
+
if (stopped)
|
|
65
|
+
return;
|
|
66
|
+
const peer = options.getPeer?.();
|
|
67
|
+
const peerService = peer && /^[a-z][a-z0-9-]{0,39}$/.test(peer.service) ? peer.service : undefined;
|
|
68
|
+
const edgePop = (0, server_js_1.metadataFromHeaders)(options.headers ?? {}).edgePop;
|
|
69
|
+
const peerRegion = peerService ? (0, collector_js_1.normalizeInfrastructureRegion)(peer?.region) : edgePop ? `edge-${edgePop}` : undefined;
|
|
70
|
+
collector.record({
|
|
71
|
+
service, region, scope: peerService ? 'internal' : 'external', direction,
|
|
72
|
+
activityType: options.activityType ?? 'communication',
|
|
73
|
+
sourceRegion: direction === 'inbound' ? peerRegion : region,
|
|
74
|
+
targetRegion: direction === 'inbound' ? region : peerRegion,
|
|
75
|
+
sourceService: direction === 'inbound' ? peerService : service,
|
|
76
|
+
targetService: direction === 'inbound' ? service : peerService,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
const inbound = () => record('inbound');
|
|
80
|
+
const originalSend = socket.send;
|
|
81
|
+
const send = function (...args) {
|
|
82
|
+
const open = this.readyState === 1;
|
|
83
|
+
// Preserve overload arguments, callback identity, return value and throws.
|
|
84
|
+
const result = Reflect.apply(originalSend, this, args);
|
|
85
|
+
if (open)
|
|
86
|
+
record('outbound');
|
|
87
|
+
return result;
|
|
88
|
+
};
|
|
89
|
+
const stop = () => {
|
|
90
|
+
if (stopped)
|
|
91
|
+
return;
|
|
92
|
+
stopped = true;
|
|
93
|
+
socket.off('message', inbound);
|
|
94
|
+
socket.off('close', stop);
|
|
95
|
+
if (socket.send === send)
|
|
96
|
+
socket.send = originalSend;
|
|
97
|
+
};
|
|
98
|
+
socket.send = send;
|
|
99
|
+
socket.on('message', inbound);
|
|
100
|
+
socket.once('close', stop);
|
|
101
|
+
return stop;
|
|
102
|
+
}
|
package/dist/esm/browser.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { OXY_ACTIVITY_ID_HEADER, OXY_EDGE_REGION_HEADER } from './index.js';
|
|
2
2
|
export const DEFAULT_ACTIVITY_ID_ROTATION_MS = 5 * 60 * 1000;
|
|
3
3
|
export const DEFAULT_EDGE_TRACE_TIMEOUT_MS = 1000;
|
|
4
|
+
export const DEFAULT_EDGE_CACHE_MS = 15000;
|
|
4
5
|
function createUuid(cryptoApi) {
|
|
5
6
|
if (!cryptoApi)
|
|
6
7
|
return null;
|
|
@@ -23,6 +24,7 @@ export function createBrowserTelemetry(options = {}) {
|
|
|
23
24
|
let activityId = null;
|
|
24
25
|
let activityIdCreatedAt = 0;
|
|
25
26
|
let edgePopPromise = null;
|
|
27
|
+
let edgePopReadAt = -Infinity;
|
|
26
28
|
const getActivityIdHeader = () => {
|
|
27
29
|
const hasInjectedBrowser = Boolean(options.location);
|
|
28
30
|
const hasBrowserRuntime = typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
@@ -60,7 +62,11 @@ export function createBrowserTelemetry(options = {}) {
|
|
|
60
62
|
}
|
|
61
63
|
};
|
|
62
64
|
const getEdgeRegionHeader = async () => {
|
|
63
|
-
|
|
65
|
+
const now = (options.now ?? Date.now)();
|
|
66
|
+
if (!edgePopPromise || now < edgePopReadAt || now - edgePopReadAt >= (options.edgeCacheMs ?? DEFAULT_EDGE_CACHE_MS)) {
|
|
67
|
+
edgePopReadAt = now;
|
|
68
|
+
edgePopPromise = discoverEdgePop();
|
|
69
|
+
}
|
|
64
70
|
const edgePop = await edgePopPromise;
|
|
65
71
|
return edgePop ? { [OXY_EDGE_REGION_HEADER]: edgePop } : {};
|
|
66
72
|
};
|