@oxy.so/telemetry 0.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 +121 -0
- package/dist/cjs/browser.js +84 -0
- package/dist/cjs/index.js +5 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/server.js +55 -0
- package/dist/esm/browser.js +77 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/server.js +45 -0
- package/dist/types/browser.d.ts +24 -0
- package/dist/types/index.d.ts +16 -0
- package/dist/types/server.d.ts +22 -0
- package/package.json +84 -0
- package/src/__tests__/browser.test.ts +47 -0
- package/src/__tests__/server.test.ts +45 -0
- package/src/browser.ts +104 -0
- package/src/index.ts +18 -0
- package/src/server.ts +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# `@oxy.so/telemetry`
|
|
2
|
+
|
|
3
|
+
Framework-agnostic primitives for anonymous, real-time Oxy activity telemetry.
|
|
4
|
+
The package has no runtime dependencies and does not import Express, Socket.IO,
|
|
5
|
+
Redis, React, React Native or Expo.
|
|
6
|
+
|
|
7
|
+
Version: `0.1.0`. This workspace package is not published yet.
|
|
8
|
+
|
|
9
|
+
## Entry points
|
|
10
|
+
|
|
11
|
+
- `@oxy.so/telemetry` — shared header names and aggregate interfaces.
|
|
12
|
+
- `@oxy.so/telemetry/browser` — ephemeral browser metadata.
|
|
13
|
+
- `@oxy.so/telemetry/server` — pure validation, route bucketing and cardinality
|
|
14
|
+
keys/interfaces.
|
|
15
|
+
|
|
16
|
+
Browser code must import the browser entry point explicitly. Server code must
|
|
17
|
+
import the server entry point explicitly; the root entry point has no platform
|
|
18
|
+
side effects.
|
|
19
|
+
|
|
20
|
+
## Browser metadata
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { getBrowserTelemetryHeaders } from '@oxy.so/telemetry/browser';
|
|
24
|
+
|
|
25
|
+
const headers = await getBrowserTelemetryHeaders();
|
|
26
|
+
await fetch('https://api.oxy.so/session/status', { headers });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`X-Oxy-Activity-Id` is generated in memory for the current page runtime and
|
|
30
|
+
rotates every five minutes. It is not placed in a cookie, local storage,
|
|
31
|
+
session storage, IndexedDB or a URL. Closing or reloading the page loses it.
|
|
32
|
+
|
|
33
|
+
`X-Oxy-Edge-Region` contains only the three-letter Cloudflare PoP code obtained
|
|
34
|
+
from the same-origin `/cdn-cgi/trace` endpoint. The helper reads no IP field and
|
|
35
|
+
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.
|
|
37
|
+
|
|
38
|
+
Use `createBrowserTelemetry()` to create an isolated runtime or inject clock,
|
|
39
|
+
crypto, location and trace-fetch dependencies in tests.
|
|
40
|
+
|
|
41
|
+
## Server primitives
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import {
|
|
45
|
+
activityFlow,
|
|
46
|
+
metadataFromHeaders,
|
|
47
|
+
presenceKeys,
|
|
48
|
+
serviceFromPath,
|
|
49
|
+
type ActivityCardinalityAdapter,
|
|
50
|
+
} from '@oxy.so/telemetry/server';
|
|
51
|
+
|
|
52
|
+
const metadata = metadataFromHeaders(request.headers);
|
|
53
|
+
const sourceRegion = metadata.edgePop ? `edge-${metadata.edgePop}` : undefined;
|
|
54
|
+
const flow = activityFlow(sourceRegion, serviceFromPath(request.path));
|
|
55
|
+
const keys = sourceRegion ? presenceKeys(sourceRegion, Date.now()) : [];
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Validation accepts a three-letter PoP and a 16–64 character opaque activity ID.
|
|
59
|
+
It ignores IP and forwarding headers. `serviceFromPath` keeps only a bounded,
|
|
60
|
+
static first route segment, so IDs and full paths cannot enter aggregates.
|
|
61
|
+
|
|
62
|
+
`ActivityCardinalityAdapter` describes the minimal storage boundary for an
|
|
63
|
+
implementation such as Redis HyperLogLog. This package does not instantiate a
|
|
64
|
+
store, keep server-global maps, start timers or send realtime events.
|
|
65
|
+
|
|
66
|
+
## Active-client definition
|
|
67
|
+
|
|
68
|
+
An active client is one distinct ephemeral activity ID observed for an edge PoP
|
|
69
|
+
during the rolling 60-second presence window. The default representation is the
|
|
70
|
+
union of the current and previous 30-second buckets returned by `presenceKeys`.
|
|
71
|
+
This is an operational count of active page runtimes, not authenticated users,
|
|
72
|
+
people, devices or sessions. ID rotation and multiple tabs mean it must never be
|
|
73
|
+
presented as an exact person count.
|
|
74
|
+
|
|
75
|
+
## Privacy boundary
|
|
76
|
+
|
|
77
|
+
Allowed public dimensions are PoP, bounded service group, processing region,
|
|
78
|
+
request count, approximate active-client cardinality and aggregate timestamps.
|
|
79
|
+
Forbidden inputs and outputs include raw or hashed IPs, coordinates derived from
|
|
80
|
+
an IP, user/account/session IDs, request bodies, query strings and raw paths.
|
|
81
|
+
The opaque activity ID exists only to estimate short-window cardinality and must
|
|
82
|
+
not be persisted as an event log or joined to product identity.
|
|
83
|
+
|
|
84
|
+
## Migration from `@oxyhq/*`
|
|
85
|
+
|
|
86
|
+
The first consumers will replace browser helpers currently owned privately by
|
|
87
|
+
`@oxyhq/core` with imports from `@oxy.so/telemetry/browser`. API collectors will
|
|
88
|
+
replace their local validation and bucketing logic with
|
|
89
|
+
`@oxy.so/telemetry/server`. This package deliberately provides no compatibility
|
|
90
|
+
re-export: consumers move imports directly to the new owner, then the old
|
|
91
|
+
private implementations are deleted in the same integration change.
|
|
92
|
+
|
|
93
|
+
The central API uses the server entry point in `0.1.0`. Core temporarily retains
|
|
94
|
+
its compatible browser helpers until this package is published and applications
|
|
95
|
+
can migrate without a broken registry dependency.
|
|
96
|
+
|
|
97
|
+
## Collector, realtime and reconnect
|
|
98
|
+
|
|
99
|
+
The intended collector records only successful, non-control-plane requests. It
|
|
100
|
+
increments request totals per `source region × bounded service × time window`
|
|
101
|
+
and sends the ephemeral activity ID to a cardinality adapter, never to an event
|
|
102
|
+
row or log.
|
|
103
|
+
|
|
104
|
+
Realtime transport publishes aggregate buckets, not individual metadata. A
|
|
105
|
+
socket event may carry `ActivityAggregate`: request count, approximate active
|
|
106
|
+
clients, source/target regions, service and window timestamps. Transport and
|
|
107
|
+
fan-out are application concerns and intentionally absent from this package.
|
|
108
|
+
|
|
109
|
+
Realtime events are not a durable replay log. After reconnect, a consumer first
|
|
110
|
+
loads the collector's current aggregate snapshot and then resumes the live
|
|
111
|
+
stream, deduplicating any overlapping window by its stable flow/window identity.
|
|
112
|
+
Reconnect backoff, socket lifecycle and snapshot endpoints belong to the
|
|
113
|
+
consumer and collector, not these pure primitives.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bun run --filter @oxy.so/telemetry typescript
|
|
119
|
+
bun run --filter @oxy.so/telemetry test
|
|
120
|
+
bun run --filter @oxy.so/telemetry build
|
|
121
|
+
```
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
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;
|
|
4
|
+
exports.createBrowserTelemetry = createBrowserTelemetry;
|
|
5
|
+
const index_js_1 = require("./index.js");
|
|
6
|
+
exports.DEFAULT_ACTIVITY_ID_ROTATION_MS = 5 * 60 * 1000;
|
|
7
|
+
exports.DEFAULT_EDGE_TRACE_TIMEOUT_MS = 1000;
|
|
8
|
+
function createUuid(cryptoApi) {
|
|
9
|
+
if (!cryptoApi)
|
|
10
|
+
return null;
|
|
11
|
+
if (typeof cryptoApi.randomUUID === 'function')
|
|
12
|
+
return cryptoApi.randomUUID();
|
|
13
|
+
const bytes = cryptoApi.getRandomValues(new Uint8Array(16));
|
|
14
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
15
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
16
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
17
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
18
|
+
}
|
|
19
|
+
function eligibleLocation(location) {
|
|
20
|
+
if (!location || (location.protocol !== 'https:' && location.protocol !== 'http:'))
|
|
21
|
+
return false;
|
|
22
|
+
return location.hostname !== 'localhost'
|
|
23
|
+
&& location.hostname !== '127.0.0.1'
|
|
24
|
+
&& location.hostname !== '[::1]';
|
|
25
|
+
}
|
|
26
|
+
function createBrowserTelemetry(options = {}) {
|
|
27
|
+
let activityId = null;
|
|
28
|
+
let activityIdCreatedAt = 0;
|
|
29
|
+
let edgePopPromise = null;
|
|
30
|
+
const getActivityIdHeader = () => {
|
|
31
|
+
const browserLocation = options.location ?? (typeof window === 'undefined' ? undefined : window.location);
|
|
32
|
+
if (!browserLocation)
|
|
33
|
+
return {};
|
|
34
|
+
const now = (options.now ?? Date.now)();
|
|
35
|
+
if (!activityId || now < activityIdCreatedAt || now - activityIdCreatedAt >= (options.activityIdRotationMs ?? exports.DEFAULT_ACTIVITY_ID_ROTATION_MS)) {
|
|
36
|
+
activityId = createUuid(options.crypto ?? globalThis.crypto);
|
|
37
|
+
if (!activityId)
|
|
38
|
+
return {};
|
|
39
|
+
activityIdCreatedAt = now;
|
|
40
|
+
}
|
|
41
|
+
return { [index_js_1.OXY_ACTIVITY_ID_HEADER]: activityId };
|
|
42
|
+
};
|
|
43
|
+
const discoverEdgePop = async () => {
|
|
44
|
+
const browserLocation = options.location ?? (typeof window === 'undefined' ? undefined : window.location);
|
|
45
|
+
if (!eligibleLocation(browserLocation))
|
|
46
|
+
return null;
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const timeout = setTimeout(() => controller.abort(), options.edgeTraceTimeoutMs ?? exports.DEFAULT_EDGE_TRACE_TIMEOUT_MS);
|
|
49
|
+
try {
|
|
50
|
+
const response = await (options.fetchTrace
|
|
51
|
+
? options.fetchTrace(controller.signal)
|
|
52
|
+
: fetch('/cdn-cgi/trace', { cache: 'no-store', credentials: 'omit', signal: controller.signal }));
|
|
53
|
+
if (!response.ok)
|
|
54
|
+
return null;
|
|
55
|
+
const match = (await response.text()).match(/^colo=([a-z]{3})$/im);
|
|
56
|
+
return match ? match[1].toLowerCase() : null;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const getEdgeRegionHeader = async () => {
|
|
66
|
+
edgePopPromise ?? (edgePopPromise = discoverEdgePop());
|
|
67
|
+
const edgePop = await edgePopPromise;
|
|
68
|
+
return edgePop ? { [index_js_1.OXY_EDGE_REGION_HEADER]: edgePop } : {};
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
getActivityIdHeader,
|
|
72
|
+
getEdgeRegionHeader,
|
|
73
|
+
async getHeaders() {
|
|
74
|
+
return { ...await getEdgeRegionHeader(), ...getActivityIdHeader() };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const defaultBrowserTelemetry = createBrowserTelemetry();
|
|
79
|
+
const getBrowserActivityIdHeader = () => defaultBrowserTelemetry.getActivityIdHeader();
|
|
80
|
+
exports.getBrowserActivityIdHeader = getBrowserActivityIdHeader;
|
|
81
|
+
const getBrowserEdgeRegionHeader = () => defaultBrowserTelemetry.getEdgeRegionHeader();
|
|
82
|
+
exports.getBrowserEdgeRegionHeader = getBrowserEdgeRegionHeader;
|
|
83
|
+
const getBrowserTelemetryHeaders = () => defaultBrowserTelemetry.getHeaders();
|
|
84
|
+
exports.getBrowserTelemetryHeaders = getBrowserTelemetryHeaders;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_ACTIVE_CLIENT_WINDOW_MS = exports.DEFAULT_PRESENCE_BUCKET_MS = void 0;
|
|
4
|
+
exports.normalizeEdgePop = normalizeEdgePop;
|
|
5
|
+
exports.normalizeActivityId = normalizeActivityId;
|
|
6
|
+
exports.metadataFromHeaders = metadataFromHeaders;
|
|
7
|
+
exports.serviceFromPath = serviceFromPath;
|
|
8
|
+
exports.activityFlow = activityFlow;
|
|
9
|
+
exports.presenceBucket = presenceBucket;
|
|
10
|
+
exports.presenceKeys = presenceKeys;
|
|
11
|
+
exports.DEFAULT_PRESENCE_BUCKET_MS = 30000;
|
|
12
|
+
exports.DEFAULT_ACTIVE_CLIENT_WINDOW_MS = 60000;
|
|
13
|
+
function firstHeaderValue(value) {
|
|
14
|
+
if (typeof value === 'string')
|
|
15
|
+
return value;
|
|
16
|
+
const first = value?.[0];
|
|
17
|
+
return typeof first === 'string' ? first : undefined;
|
|
18
|
+
}
|
|
19
|
+
function normalizeEdgePop(value) {
|
|
20
|
+
return firstHeaderValue(value)?.match(/^[a-z]{3}$/i)?.[0]?.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
function normalizeActivityId(value) {
|
|
23
|
+
const candidate = firstHeaderValue(value);
|
|
24
|
+
return candidate && /^[a-z0-9_-]{16,64}$/i.test(candidate) ? candidate : undefined;
|
|
25
|
+
}
|
|
26
|
+
function metadataFromHeaders(headers) {
|
|
27
|
+
const forwardedPop = normalizeEdgePop(headers['x-oxy-edge-region']);
|
|
28
|
+
const ray = firstHeaderValue(headers['cf-ray']);
|
|
29
|
+
const rayPop = ray?.match(/-([a-z]{3})$/i)?.[1]?.toLowerCase();
|
|
30
|
+
const activityId = normalizeActivityId(headers['x-oxy-activity-id']);
|
|
31
|
+
return {
|
|
32
|
+
...(activityId ? { activityId } : {}),
|
|
33
|
+
...(forwardedPop || rayPop ? { edgePop: forwardedPop ?? rayPop } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function serviceFromPath(path) {
|
|
37
|
+
const segment = path.split('/').filter(Boolean)[0];
|
|
38
|
+
return segment && /^[a-z][a-z0-9-]{0,31}$/i.test(segment) ? segment.toLowerCase() : 'platform';
|
|
39
|
+
}
|
|
40
|
+
function activityFlow(sourceRegion, service) {
|
|
41
|
+
return { sourceRegion, service, key: `${sourceRegion ?? ''}|${service}` };
|
|
42
|
+
}
|
|
43
|
+
function presenceBucket(observedAt, bucketMs = exports.DEFAULT_PRESENCE_BUCKET_MS) {
|
|
44
|
+
if (!Number.isFinite(observedAt) || !Number.isFinite(bucketMs) || bucketMs <= 0) {
|
|
45
|
+
throw new RangeError('observedAt and bucketMs must define a finite positive bucket');
|
|
46
|
+
}
|
|
47
|
+
return Math.floor(observedAt / bucketMs);
|
|
48
|
+
}
|
|
49
|
+
function presenceKeys(sourceRegion, observedAt, bucketMs = exports.DEFAULT_PRESENCE_BUCKET_MS) {
|
|
50
|
+
const current = presenceBucket(observedAt, bucketMs);
|
|
51
|
+
return [
|
|
52
|
+
`platform-activity:clients:${sourceRegion}:${current}`,
|
|
53
|
+
`platform-activity:clients:${sourceRegion}:${current - 1}`,
|
|
54
|
+
];
|
|
55
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { OXY_ACTIVITY_ID_HEADER, OXY_EDGE_REGION_HEADER } from './index.js';
|
|
2
|
+
export const DEFAULT_ACTIVITY_ID_ROTATION_MS = 5 * 60 * 1000;
|
|
3
|
+
export const DEFAULT_EDGE_TRACE_TIMEOUT_MS = 1000;
|
|
4
|
+
function createUuid(cryptoApi) {
|
|
5
|
+
if (!cryptoApi)
|
|
6
|
+
return null;
|
|
7
|
+
if (typeof cryptoApi.randomUUID === 'function')
|
|
8
|
+
return cryptoApi.randomUUID();
|
|
9
|
+
const bytes = cryptoApi.getRandomValues(new Uint8Array(16));
|
|
10
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
11
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
12
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
13
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
14
|
+
}
|
|
15
|
+
function eligibleLocation(location) {
|
|
16
|
+
if (!location || (location.protocol !== 'https:' && location.protocol !== 'http:'))
|
|
17
|
+
return false;
|
|
18
|
+
return location.hostname !== 'localhost'
|
|
19
|
+
&& location.hostname !== '127.0.0.1'
|
|
20
|
+
&& location.hostname !== '[::1]';
|
|
21
|
+
}
|
|
22
|
+
export function createBrowserTelemetry(options = {}) {
|
|
23
|
+
let activityId = null;
|
|
24
|
+
let activityIdCreatedAt = 0;
|
|
25
|
+
let edgePopPromise = null;
|
|
26
|
+
const getActivityIdHeader = () => {
|
|
27
|
+
const browserLocation = options.location ?? (typeof window === 'undefined' ? undefined : window.location);
|
|
28
|
+
if (!browserLocation)
|
|
29
|
+
return {};
|
|
30
|
+
const now = (options.now ?? Date.now)();
|
|
31
|
+
if (!activityId || now < activityIdCreatedAt || now - activityIdCreatedAt >= (options.activityIdRotationMs ?? DEFAULT_ACTIVITY_ID_ROTATION_MS)) {
|
|
32
|
+
activityId = createUuid(options.crypto ?? globalThis.crypto);
|
|
33
|
+
if (!activityId)
|
|
34
|
+
return {};
|
|
35
|
+
activityIdCreatedAt = now;
|
|
36
|
+
}
|
|
37
|
+
return { [OXY_ACTIVITY_ID_HEADER]: activityId };
|
|
38
|
+
};
|
|
39
|
+
const discoverEdgePop = async () => {
|
|
40
|
+
const browserLocation = options.location ?? (typeof window === 'undefined' ? undefined : window.location);
|
|
41
|
+
if (!eligibleLocation(browserLocation))
|
|
42
|
+
return null;
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const timeout = setTimeout(() => controller.abort(), options.edgeTraceTimeoutMs ?? DEFAULT_EDGE_TRACE_TIMEOUT_MS);
|
|
45
|
+
try {
|
|
46
|
+
const response = await (options.fetchTrace
|
|
47
|
+
? options.fetchTrace(controller.signal)
|
|
48
|
+
: fetch('/cdn-cgi/trace', { cache: 'no-store', credentials: 'omit', signal: controller.signal }));
|
|
49
|
+
if (!response.ok)
|
|
50
|
+
return null;
|
|
51
|
+
const match = (await response.text()).match(/^colo=([a-z]{3})$/im);
|
|
52
|
+
return match ? match[1].toLowerCase() : null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const getEdgeRegionHeader = async () => {
|
|
62
|
+
edgePopPromise ?? (edgePopPromise = discoverEdgePop());
|
|
63
|
+
const edgePop = await edgePopPromise;
|
|
64
|
+
return edgePop ? { [OXY_EDGE_REGION_HEADER]: edgePop } : {};
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
getActivityIdHeader,
|
|
68
|
+
getEdgeRegionHeader,
|
|
69
|
+
async getHeaders() {
|
|
70
|
+
return { ...await getEdgeRegionHeader(), ...getActivityIdHeader() };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const defaultBrowserTelemetry = createBrowserTelemetry();
|
|
75
|
+
export const getBrowserActivityIdHeader = () => defaultBrowserTelemetry.getActivityIdHeader();
|
|
76
|
+
export const getBrowserEdgeRegionHeader = () => defaultBrowserTelemetry.getEdgeRegionHeader();
|
|
77
|
+
export const getBrowserTelemetryHeaders = () => defaultBrowserTelemetry.getHeaders();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const DEFAULT_PRESENCE_BUCKET_MS = 30000;
|
|
2
|
+
export const DEFAULT_ACTIVE_CLIENT_WINDOW_MS = 60000;
|
|
3
|
+
function firstHeaderValue(value) {
|
|
4
|
+
if (typeof value === 'string')
|
|
5
|
+
return value;
|
|
6
|
+
const first = value?.[0];
|
|
7
|
+
return typeof first === 'string' ? first : undefined;
|
|
8
|
+
}
|
|
9
|
+
export function normalizeEdgePop(value) {
|
|
10
|
+
return firstHeaderValue(value)?.match(/^[a-z]{3}$/i)?.[0]?.toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
export function normalizeActivityId(value) {
|
|
13
|
+
const candidate = firstHeaderValue(value);
|
|
14
|
+
return candidate && /^[a-z0-9_-]{16,64}$/i.test(candidate) ? candidate : undefined;
|
|
15
|
+
}
|
|
16
|
+
export function metadataFromHeaders(headers) {
|
|
17
|
+
const forwardedPop = normalizeEdgePop(headers['x-oxy-edge-region']);
|
|
18
|
+
const ray = firstHeaderValue(headers['cf-ray']);
|
|
19
|
+
const rayPop = ray?.match(/-([a-z]{3})$/i)?.[1]?.toLowerCase();
|
|
20
|
+
const activityId = normalizeActivityId(headers['x-oxy-activity-id']);
|
|
21
|
+
return {
|
|
22
|
+
...(activityId ? { activityId } : {}),
|
|
23
|
+
...(forwardedPop || rayPop ? { edgePop: forwardedPop ?? rayPop } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function serviceFromPath(path) {
|
|
27
|
+
const segment = path.split('/').filter(Boolean)[0];
|
|
28
|
+
return segment && /^[a-z][a-z0-9-]{0,31}$/i.test(segment) ? segment.toLowerCase() : 'platform';
|
|
29
|
+
}
|
|
30
|
+
export function activityFlow(sourceRegion, service) {
|
|
31
|
+
return { sourceRegion, service, key: `${sourceRegion ?? ''}|${service}` };
|
|
32
|
+
}
|
|
33
|
+
export function presenceBucket(observedAt, bucketMs = DEFAULT_PRESENCE_BUCKET_MS) {
|
|
34
|
+
if (!Number.isFinite(observedAt) || !Number.isFinite(bucketMs) || bucketMs <= 0) {
|
|
35
|
+
throw new RangeError('observedAt and bucketMs must define a finite positive bucket');
|
|
36
|
+
}
|
|
37
|
+
return Math.floor(observedAt / bucketMs);
|
|
38
|
+
}
|
|
39
|
+
export function presenceKeys(sourceRegion, observedAt, bucketMs = DEFAULT_PRESENCE_BUCKET_MS) {
|
|
40
|
+
const current = presenceBucket(observedAt, bucketMs);
|
|
41
|
+
return [
|
|
42
|
+
`platform-activity:clients:${sourceRegion}:${current}`,
|
|
43
|
+
`platform-activity:clients:${sourceRegion}:${current - 1}`,
|
|
44
|
+
];
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const DEFAULT_ACTIVITY_ID_ROTATION_MS: number;
|
|
2
|
+
export declare const DEFAULT_EDGE_TRACE_TIMEOUT_MS = 1000;
|
|
3
|
+
interface TraceResponse {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
text(): Promise<string>;
|
|
6
|
+
}
|
|
7
|
+
export interface BrowserTelemetryOptions {
|
|
8
|
+
activityIdRotationMs?: number;
|
|
9
|
+
edgeTraceTimeoutMs?: number;
|
|
10
|
+
now?: () => number;
|
|
11
|
+
crypto?: Pick<Crypto, 'getRandomValues' | 'randomUUID'>;
|
|
12
|
+
location?: Pick<Location, 'hostname' | 'protocol'>;
|
|
13
|
+
fetchTrace?: (signal: AbortSignal) => Promise<TraceResponse>;
|
|
14
|
+
}
|
|
15
|
+
export interface BrowserTelemetry {
|
|
16
|
+
getActivityIdHeader(): Record<string, string>;
|
|
17
|
+
getEdgeRegionHeader(): Promise<Record<string, string>>;
|
|
18
|
+
getHeaders(): Promise<Record<string, string>>;
|
|
19
|
+
}
|
|
20
|
+
export declare function createBrowserTelemetry(options?: BrowserTelemetryOptions): BrowserTelemetry;
|
|
21
|
+
export declare const getBrowserActivityIdHeader: () => Record<string, string>;
|
|
22
|
+
export declare const getBrowserEdgeRegionHeader: () => Promise<Record<string, string>>;
|
|
23
|
+
export declare const getBrowserTelemetryHeaders: () => Promise<Record<string, string>>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const OXY_ACTIVITY_ID_HEADER = "X-Oxy-Activity-Id";
|
|
2
|
+
export declare const OXY_EDGE_REGION_HEADER = "X-Oxy-Edge-Region";
|
|
3
|
+
export interface AnonymousActivityMetadata {
|
|
4
|
+
activityId?: string;
|
|
5
|
+
edgePop?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ActivityAggregate {
|
|
8
|
+
sourceRegion?: string;
|
|
9
|
+
targetRegion: string;
|
|
10
|
+
service: string;
|
|
11
|
+
requests: number;
|
|
12
|
+
activeClients: number;
|
|
13
|
+
windowStartedAt: string;
|
|
14
|
+
emittedAt: string;
|
|
15
|
+
direction: 'inbound';
|
|
16
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ActivityAggregate, AnonymousActivityMetadata } from './index.js';
|
|
2
|
+
export declare const DEFAULT_PRESENCE_BUCKET_MS = 30000;
|
|
3
|
+
export declare const DEFAULT_ACTIVE_CLIENT_WINDOW_MS = 60000;
|
|
4
|
+
export type TelemetryHeaderValue = string | readonly string[] | undefined;
|
|
5
|
+
export type TelemetryHeaders = Readonly<Record<string, TelemetryHeaderValue>>;
|
|
6
|
+
export interface ActivityCardinalityAdapter {
|
|
7
|
+
add(key: string, activityId: string, ttlSeconds: number): Promise<void>;
|
|
8
|
+
count(keys: readonly string[]): Promise<number>;
|
|
9
|
+
}
|
|
10
|
+
export interface ActivityFlow {
|
|
11
|
+
sourceRegion?: string;
|
|
12
|
+
service: string;
|
|
13
|
+
key: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function normalizeEdgePop(value: TelemetryHeaderValue): string | undefined;
|
|
16
|
+
export declare function normalizeActivityId(value: TelemetryHeaderValue): string | undefined;
|
|
17
|
+
export declare function metadataFromHeaders(headers: TelemetryHeaders): AnonymousActivityMetadata;
|
|
18
|
+
export declare function serviceFromPath(path: string): string;
|
|
19
|
+
export declare function activityFlow(sourceRegion: string | undefined, service: string): ActivityFlow;
|
|
20
|
+
export declare function presenceBucket(observedAt: number, bucketMs?: number): number;
|
|
21
|
+
export declare function presenceKeys(sourceRegion: string, observedAt: number, bucketMs?: number): readonly [string, string];
|
|
22
|
+
export type ActivityBucket = ActivityAggregate;
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oxy.so/telemetry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic anonymous activity telemetry primitives for Oxy services.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/cjs/index.js",
|
|
7
|
+
"module": "dist/esm/index.js",
|
|
8
|
+
"types": "dist/types/index.d.ts",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": {
|
|
16
|
+
"types": "./dist/types/index.d.ts",
|
|
17
|
+
"default": "./dist/esm/index.js"
|
|
18
|
+
},
|
|
19
|
+
"require": {
|
|
20
|
+
"types": "./dist/types/index.d.ts",
|
|
21
|
+
"default": "./dist/cjs/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"./browser": {
|
|
25
|
+
"browser": {
|
|
26
|
+
"types": "./dist/types/browser.d.ts",
|
|
27
|
+
"default": "./dist/esm/browser.js"
|
|
28
|
+
},
|
|
29
|
+
"import": {
|
|
30
|
+
"types": "./dist/types/browser.d.ts",
|
|
31
|
+
"default": "./dist/esm/browser.js"
|
|
32
|
+
},
|
|
33
|
+
"require": {
|
|
34
|
+
"types": "./dist/types/browser.d.ts",
|
|
35
|
+
"default": "./dist/cjs/browser.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"./server": {
|
|
39
|
+
"import": {
|
|
40
|
+
"types": "./dist/types/server.d.ts",
|
|
41
|
+
"default": "./dist/esm/server.js"
|
|
42
|
+
},
|
|
43
|
+
"require": {
|
|
44
|
+
"types": "./dist/types/server.d.ts",
|
|
45
|
+
"default": "./dist/cjs/server.js"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"./package.json": "./package.json"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist",
|
|
52
|
+
"src"
|
|
53
|
+
],
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "https://github.com/oxyhq/sdk",
|
|
57
|
+
"directory": "packages/telemetry"
|
|
58
|
+
},
|
|
59
|
+
"license": "Apache-2.0",
|
|
60
|
+
"author": "Oxy",
|
|
61
|
+
"homepage": "https://oxy.so",
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=18.0.0"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "bun run clean && bun run build:cjs && bun run build:esm && bun run build:types",
|
|
67
|
+
"build:cjs": "tsc -p tsconfig.cjs.json && node scripts/mark-cjs.mjs",
|
|
68
|
+
"build:esm": "tsc -p tsconfig.esm.json",
|
|
69
|
+
"build:types": "tsc -p tsconfig.types.json",
|
|
70
|
+
"clean": "rm -rf dist",
|
|
71
|
+
"typescript": "tsc --noEmit",
|
|
72
|
+
"test": "jest",
|
|
73
|
+
"lint": "biome lint --error-on-warnings ./src",
|
|
74
|
+
"prepublishOnly": "node ../../scripts/assert-bun-publish.mjs && bun run typescript && bun run test && bun run build"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@biomejs/biome": "^1.9.4",
|
|
78
|
+
"@types/jest": "^30.0.0",
|
|
79
|
+
"jest": "^30.5.1",
|
|
80
|
+
"jest-environment-jsdom": "^30.5.1",
|
|
81
|
+
"ts-jest": "^29.4.12",
|
|
82
|
+
"typescript": "^5.9.2"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createBrowserTelemetry } from '../browser';
|
|
2
|
+
|
|
3
|
+
describe('browser telemetry', () => {
|
|
4
|
+
it('rotates an ephemeral activity id without persistence', () => {
|
|
5
|
+
let now = 1_000;
|
|
6
|
+
let sequence = 0;
|
|
7
|
+
const telemetry = createBrowserTelemetry({
|
|
8
|
+
location: { hostname: 'mention.earth', protocol: 'https:' } as Location,
|
|
9
|
+
now: () => now,
|
|
10
|
+
crypto: {
|
|
11
|
+
randomUUID: () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, '0')}`,
|
|
12
|
+
getRandomValues: crypto.getRandomValues.bind(crypto),
|
|
13
|
+
},
|
|
14
|
+
activityIdRotationMs: 100,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const first = telemetry.getActivityIdHeader()['X-Oxy-Activity-Id'];
|
|
18
|
+
expect(telemetry.getActivityIdHeader()['X-Oxy-Activity-Id']).toBe(first);
|
|
19
|
+
now += 100;
|
|
20
|
+
expect(telemetry.getActivityIdHeader()['X-Oxy-Activity-Id']).not.toBe(first);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('extracts only the Cloudflare serving PoP and caches discovery', async () => {
|
|
24
|
+
const fetchTrace = jest.fn(async () => ({
|
|
25
|
+
ok: true,
|
|
26
|
+
text: async () => 'ip=203.0.113.8\ncolo=MAD\nloc=ES',
|
|
27
|
+
}));
|
|
28
|
+
const telemetry = createBrowserTelemetry({
|
|
29
|
+
location: { hostname: 'alia.onl', protocol: 'https:' } as Location,
|
|
30
|
+
fetchTrace,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await expect(telemetry.getEdgeRegionHeader()).resolves.toEqual({ 'X-Oxy-Edge-Region': 'mad' });
|
|
34
|
+
await telemetry.getEdgeRegionHeader();
|
|
35
|
+
expect(fetchTrace).toHaveBeenCalledTimes(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not query trace from loopback', async () => {
|
|
39
|
+
const fetchTrace = jest.fn();
|
|
40
|
+
const telemetry = createBrowserTelemetry({
|
|
41
|
+
location: { hostname: 'localhost', protocol: 'http:' } as Location,
|
|
42
|
+
fetchTrace,
|
|
43
|
+
});
|
|
44
|
+
await expect(telemetry.getEdgeRegionHeader()).resolves.toEqual({});
|
|
45
|
+
expect(fetchTrace).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
activityFlow,
|
|
3
|
+
metadataFromHeaders,
|
|
4
|
+
normalizeActivityId,
|
|
5
|
+
normalizeEdgePop,
|
|
6
|
+
presenceBucket,
|
|
7
|
+
presenceKeys,
|
|
8
|
+
serviceFromPath,
|
|
9
|
+
} from '../server';
|
|
10
|
+
|
|
11
|
+
describe('server telemetry primitives', () => {
|
|
12
|
+
it('prefers a validated forwarded PoP and accepts Cloudflare ray fallback', () => {
|
|
13
|
+
expect(metadataFromHeaders({
|
|
14
|
+
'x-oxy-edge-region': 'MAD',
|
|
15
|
+
'cf-ray': 'deadbeef-CDG',
|
|
16
|
+
'x-oxy-activity-id': 'anonymous-runtime-a1',
|
|
17
|
+
})).toEqual({ edgePop: 'mad', activityId: 'anonymous-runtime-a1' });
|
|
18
|
+
expect(metadataFromHeaders({ 'cf-ray': 'deadbeef-CDG' })).toEqual({ edgePop: 'cdg' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects identifying or malformed metadata', () => {
|
|
22
|
+
expect(normalizeEdgePop('Madrid')).toBeUndefined();
|
|
23
|
+
expect(normalizeActivityId('short')).toBeUndefined();
|
|
24
|
+
expect(metadataFromHeaders({ 'x-forwarded-for': '203.0.113.8' })).toEqual({});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reduces paths to bounded route groups', () => {
|
|
28
|
+
expect(serviceFromPath('/messages/private-id')).toBe('messages');
|
|
29
|
+
expect(serviceFromPath('/123/private-id')).toBe('platform');
|
|
30
|
+
expect(activityFlow('edge-mad', 'messages')).toEqual({
|
|
31
|
+
sourceRegion: 'edge-mad',
|
|
32
|
+
service: 'messages',
|
|
33
|
+
key: 'edge-mad|messages',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('creates current and previous cardinality buckets', () => {
|
|
38
|
+
expect(presenceBucket(65_000)).toBe(2);
|
|
39
|
+
expect(presenceKeys('edge-mad', 65_000)).toEqual([
|
|
40
|
+
'platform-activity:clients:edge-mad:2',
|
|
41
|
+
'platform-activity:clients:edge-mad:1',
|
|
42
|
+
]);
|
|
43
|
+
expect(() => presenceBucket(1, 0)).toThrow(RangeError);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { OXY_ACTIVITY_ID_HEADER, OXY_EDGE_REGION_HEADER } from './index.js';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_ACTIVITY_ID_ROTATION_MS = 5 * 60 * 1_000;
|
|
4
|
+
export const DEFAULT_EDGE_TRACE_TIMEOUT_MS = 1_000;
|
|
5
|
+
|
|
6
|
+
interface TraceResponse {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
text(): Promise<string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BrowserTelemetryOptions {
|
|
12
|
+
activityIdRotationMs?: number;
|
|
13
|
+
edgeTraceTimeoutMs?: number;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
crypto?: Pick<Crypto, 'getRandomValues' | 'randomUUID'>;
|
|
16
|
+
location?: Pick<Location, 'hostname' | 'protocol'>;
|
|
17
|
+
fetchTrace?: (signal: AbortSignal) => Promise<TraceResponse>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BrowserTelemetry {
|
|
21
|
+
getActivityIdHeader(): Record<string, string>;
|
|
22
|
+
getEdgeRegionHeader(): Promise<Record<string, string>>;
|
|
23
|
+
getHeaders(): Promise<Record<string, string>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createUuid(cryptoApi: Pick<Crypto, 'getRandomValues' | 'randomUUID'> | undefined): string | null {
|
|
27
|
+
if (!cryptoApi) return null;
|
|
28
|
+
if (typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID();
|
|
29
|
+
|
|
30
|
+
const bytes = cryptoApi.getRandomValues(new Uint8Array(16));
|
|
31
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
32
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
33
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
34
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function eligibleLocation(location: Pick<Location, 'hostname' | 'protocol'> | undefined): boolean {
|
|
38
|
+
if (!location || (location.protocol !== 'https:' && location.protocol !== 'http:')) return false;
|
|
39
|
+
return location.hostname !== 'localhost'
|
|
40
|
+
&& location.hostname !== '127.0.0.1'
|
|
41
|
+
&& location.hostname !== '[::1]';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createBrowserTelemetry(options: BrowserTelemetryOptions = {}): BrowserTelemetry {
|
|
45
|
+
let activityId: string | null = null;
|
|
46
|
+
let activityIdCreatedAt = 0;
|
|
47
|
+
let edgePopPromise: Promise<string | null> | null = null;
|
|
48
|
+
|
|
49
|
+
const getActivityIdHeader = (): Record<string, string> => {
|
|
50
|
+
const browserLocation = options.location ?? (typeof window === 'undefined' ? undefined : window.location);
|
|
51
|
+
if (!browserLocation) return {};
|
|
52
|
+
const now = (options.now ?? Date.now)();
|
|
53
|
+
if (!activityId || now < activityIdCreatedAt || now - activityIdCreatedAt >= (options.activityIdRotationMs ?? DEFAULT_ACTIVITY_ID_ROTATION_MS)) {
|
|
54
|
+
activityId = createUuid(options.crypto ?? globalThis.crypto);
|
|
55
|
+
if (!activityId) return {};
|
|
56
|
+
activityIdCreatedAt = now;
|
|
57
|
+
}
|
|
58
|
+
return { [OXY_ACTIVITY_ID_HEADER]: activityId };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const discoverEdgePop = async (): Promise<string | null> => {
|
|
62
|
+
const browserLocation = options.location ?? (typeof window === 'undefined' ? undefined : window.location);
|
|
63
|
+
if (!eligibleLocation(browserLocation)) return null;
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timeout = setTimeout(() => controller.abort(), options.edgeTraceTimeoutMs ?? DEFAULT_EDGE_TRACE_TIMEOUT_MS);
|
|
66
|
+
try {
|
|
67
|
+
const response = await (options.fetchTrace
|
|
68
|
+
? options.fetchTrace(controller.signal)
|
|
69
|
+
: fetch('/cdn-cgi/trace', { cache: 'no-store', credentials: 'omit', signal: controller.signal }));
|
|
70
|
+
if (!response.ok) return null;
|
|
71
|
+
const match = (await response.text()).match(/^colo=([a-z]{3})$/im);
|
|
72
|
+
return match ? match[1].toLowerCase() : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const getEdgeRegionHeader = async (): Promise<Record<string, string>> => {
|
|
81
|
+
edgePopPromise ??= discoverEdgePop();
|
|
82
|
+
const edgePop = await edgePopPromise;
|
|
83
|
+
return edgePop ? { [OXY_EDGE_REGION_HEADER]: edgePop } : {};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
getActivityIdHeader,
|
|
88
|
+
getEdgeRegionHeader,
|
|
89
|
+
async getHeaders(): Promise<Record<string, string>> {
|
|
90
|
+
return { ...await getEdgeRegionHeader(), ...getActivityIdHeader() };
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const defaultBrowserTelemetry = createBrowserTelemetry();
|
|
96
|
+
|
|
97
|
+
export const getBrowserActivityIdHeader = (): Record<string, string> =>
|
|
98
|
+
defaultBrowserTelemetry.getActivityIdHeader();
|
|
99
|
+
|
|
100
|
+
export const getBrowserEdgeRegionHeader = (): Promise<Record<string, string>> =>
|
|
101
|
+
defaultBrowserTelemetry.getEdgeRegionHeader();
|
|
102
|
+
|
|
103
|
+
export const getBrowserTelemetryHeaders = (): Promise<Record<string, string>> =>
|
|
104
|
+
defaultBrowserTelemetry.getHeaders();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const OXY_ACTIVITY_ID_HEADER = 'X-Oxy-Activity-Id';
|
|
2
|
+
export const OXY_EDGE_REGION_HEADER = 'X-Oxy-Edge-Region';
|
|
3
|
+
|
|
4
|
+
export interface AnonymousActivityMetadata {
|
|
5
|
+
activityId?: string;
|
|
6
|
+
edgePop?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ActivityAggregate {
|
|
10
|
+
sourceRegion?: string;
|
|
11
|
+
targetRegion: string;
|
|
12
|
+
service: string;
|
|
13
|
+
requests: number;
|
|
14
|
+
activeClients: number;
|
|
15
|
+
windowStartedAt: string;
|
|
16
|
+
emittedAt: string;
|
|
17
|
+
direction: 'inbound';
|
|
18
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ActivityAggregate, AnonymousActivityMetadata } from './index.js';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_PRESENCE_BUCKET_MS = 30_000;
|
|
4
|
+
export const DEFAULT_ACTIVE_CLIENT_WINDOW_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
export type TelemetryHeaderValue = string | readonly string[] | undefined;
|
|
7
|
+
export type TelemetryHeaders = Readonly<Record<string, TelemetryHeaderValue>>;
|
|
8
|
+
|
|
9
|
+
export interface ActivityCardinalityAdapter {
|
|
10
|
+
add(key: string, activityId: string, ttlSeconds: number): Promise<void>;
|
|
11
|
+
count(keys: readonly string[]): Promise<number>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ActivityFlow {
|
|
15
|
+
sourceRegion?: string;
|
|
16
|
+
service: string;
|
|
17
|
+
key: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function firstHeaderValue(value: TelemetryHeaderValue): string | undefined {
|
|
21
|
+
if (typeof value === 'string') return value;
|
|
22
|
+
const first = value?.[0];
|
|
23
|
+
return typeof first === 'string' ? first : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeEdgePop(value: TelemetryHeaderValue): string | undefined {
|
|
27
|
+
return firstHeaderValue(value)?.match(/^[a-z]{3}$/i)?.[0]?.toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeActivityId(value: TelemetryHeaderValue): string | undefined {
|
|
31
|
+
const candidate = firstHeaderValue(value);
|
|
32
|
+
return candidate && /^[a-z0-9_-]{16,64}$/i.test(candidate) ? candidate : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function metadataFromHeaders(headers: TelemetryHeaders): AnonymousActivityMetadata {
|
|
36
|
+
const forwardedPop = normalizeEdgePop(headers['x-oxy-edge-region']);
|
|
37
|
+
const ray = firstHeaderValue(headers['cf-ray']);
|
|
38
|
+
const rayPop = ray?.match(/-([a-z]{3})$/i)?.[1]?.toLowerCase();
|
|
39
|
+
const activityId = normalizeActivityId(headers['x-oxy-activity-id']);
|
|
40
|
+
return {
|
|
41
|
+
...(activityId ? { activityId } : {}),
|
|
42
|
+
...(forwardedPop || rayPop ? { edgePop: forwardedPop ?? rayPop } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function serviceFromPath(path: string): string {
|
|
47
|
+
const segment = path.split('/').filter(Boolean)[0];
|
|
48
|
+
return segment && /^[a-z][a-z0-9-]{0,31}$/i.test(segment) ? segment.toLowerCase() : 'platform';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function activityFlow(sourceRegion: string | undefined, service: string): ActivityFlow {
|
|
52
|
+
return { sourceRegion, service, key: `${sourceRegion ?? ''}|${service}` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function presenceBucket(observedAt: number, bucketMs = DEFAULT_PRESENCE_BUCKET_MS): number {
|
|
56
|
+
if (!Number.isFinite(observedAt) || !Number.isFinite(bucketMs) || bucketMs <= 0) {
|
|
57
|
+
throw new RangeError('observedAt and bucketMs must define a finite positive bucket');
|
|
58
|
+
}
|
|
59
|
+
return Math.floor(observedAt / bucketMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function presenceKeys(sourceRegion: string, observedAt: number, bucketMs = DEFAULT_PRESENCE_BUCKET_MS): readonly [string, string] {
|
|
63
|
+
const current = presenceBucket(observedAt, bucketMs);
|
|
64
|
+
return [
|
|
65
|
+
`platform-activity:clients:${sourceRegion}:${current}`,
|
|
66
|
+
`platform-activity:clients:${sourceRegion}:${current - 1}`,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ActivityBucket = ActivityAggregate;
|