@jeffjassky/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/LICENSE +21 -0
- package/README.md +135 -0
- package/dist/cli.cjs +310 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.js +307 -0
- package/dist/cli.js.map +1 -0
- package/dist/core.cjs +276 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.js +271 -0
- package/dist/core.js.map +1 -0
- package/dist/electron.cjs +287 -0
- package/dist/electron.cjs.map +1 -0
- package/dist/electron.js +282 -0
- package/dist/electron.js.map +1 -0
- package/dist/index.cjs +2394 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +2350 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +303 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +278 -0
- package/dist/react.js.map +1 -0
- package/dist/ui/_assets/index-3jcToTuP.js +41 -0
- package/dist/ui/_assets/index-3jcToTuP.js.map +1 -0
- package/dist/ui/_assets/index-CXiDp_v6.css +1 -0
- package/dist/ui/index.html +14 -0
- package/dist/vue.cjs +267 -0
- package/dist/vue.cjs.map +1 -0
- package/dist/vue.js +262 -0
- package/dist/vue.js.map +1 -0
- package/dist/web.cjs +335 -0
- package/dist/web.cjs.map +1 -0
- package/dist/web.js +332 -0
- package/dist/web.js.map +1 -0
- package/package.json +126 -0
- package/types/cli.d.ts +20 -0
- package/types/core.d.ts +126 -0
- package/types/electron.d.ts +24 -0
- package/types/index.d.ts +763 -0
- package/types/react.d.ts +19 -0
- package/types/test-d.ts +687 -0
- package/types/vue.d.ts +17 -0
- package/types/web.d.ts +19 -0
package/types/test-d.ts
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-only exercise of the public declarations. Never executed — `tsc
|
|
3
|
+
* --noEmit` failing here means the .d.ts files drifted from the source.
|
|
4
|
+
*
|
|
5
|
+
* Hand-written types rot within a day. On featureboard this file immediately
|
|
6
|
+
* caught that `types/` was missing FOUR features added the same afternoon.
|
|
7
|
+
* Every exported symbol must appear below. See standards/traps.md #9.
|
|
8
|
+
*
|
|
9
|
+
* THE RULE THAT MATTERS: a value export must be exercised AS A VALUE — read a
|
|
10
|
+
* property off it, call it, assign it to a typed binding. Naming a symbol in an
|
|
11
|
+
* `import type` proves only that some declaration exists; it says nothing about
|
|
12
|
+
* whether the declaration is a const the package ships or a bare type alias.
|
|
13
|
+
* That gap is exactly how `TelemetryKind`, `LogLevel`, `Env`, `Origin`,
|
|
14
|
+
* `KeyKind` and `TenantMode` shipped as runtime objects in `src/` and type-only
|
|
15
|
+
* unions in `types/` — a host writing `TelemetryKind.Usage` got working code
|
|
16
|
+
* that failed `tsc`, and this file compiled clean the whole time. Every entry
|
|
17
|
+
* point's export list is walked below; if a symbol is exported and not here, it
|
|
18
|
+
* is drift waiting to happen.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import type {
|
|
22
|
+
AttrsOf,
|
|
23
|
+
Checkpoint,
|
|
24
|
+
ClientContext,
|
|
25
|
+
CreateTelemetryConfig,
|
|
26
|
+
DimSource,
|
|
27
|
+
EmitBase,
|
|
28
|
+
EmitInput,
|
|
29
|
+
EmitResult,
|
|
30
|
+
EntityRef,
|
|
31
|
+
EventSpec,
|
|
32
|
+
ForgetResult,
|
|
33
|
+
Logger,
|
|
34
|
+
MetricsOf,
|
|
35
|
+
Registry,
|
|
36
|
+
RollupSpec,
|
|
37
|
+
Scoped,
|
|
38
|
+
SubjectInput,
|
|
39
|
+
Telemetry,
|
|
40
|
+
TelemetryCounters,
|
|
41
|
+
} from './index.js';
|
|
42
|
+
// VALUE imports — the vocabulary ships as `const` objects, so importing these
|
|
43
|
+
// with `import type` would have hidden the very drift this file exists to catch
|
|
44
|
+
import {
|
|
45
|
+
boundedMeta,
|
|
46
|
+
createTelemetry,
|
|
47
|
+
defineRegistry,
|
|
48
|
+
newId,
|
|
49
|
+
plain,
|
|
50
|
+
resolveDim,
|
|
51
|
+
traceKeep,
|
|
52
|
+
truncate,
|
|
53
|
+
validateRegistry,
|
|
54
|
+
isPlatformScope,
|
|
55
|
+
BODY_MAX_CHARS,
|
|
56
|
+
Env,
|
|
57
|
+
INDEX_BUDGET,
|
|
58
|
+
LogLevel,
|
|
59
|
+
Origin,
|
|
60
|
+
PLATFORM_SCOPE,
|
|
61
|
+
RETENTION_DAYS,
|
|
62
|
+
SAMPLE_RATE,
|
|
63
|
+
SCHEMA_VERSION,
|
|
64
|
+
TelemetryKind,
|
|
65
|
+
} from './index.js';
|
|
66
|
+
|
|
67
|
+
// ── the registry keeps literal shapes through defineRegistry ──
|
|
68
|
+
const registry = defineRegistry({
|
|
69
|
+
'user.signed_up': {
|
|
70
|
+
kind: 'event',
|
|
71
|
+
origin: 'server',
|
|
72
|
+
subjects: ['user', 'org'],
|
|
73
|
+
attrs: z.object({ source: z.string().max(64), plan: z.enum(['free', 'pro']) }),
|
|
74
|
+
indexedAttrs: ['source'],
|
|
75
|
+
rollups: [
|
|
76
|
+
{ by: ['subject'], subjects: ['user'], actors: ['user', 'system'], capture: ['attr:source'] },
|
|
77
|
+
{ as: 'activity', by: ['subject'], subjects: ['user'], bucket: 'day', retentionDays: 730 },
|
|
78
|
+
],
|
|
79
|
+
description: 'Account created',
|
|
80
|
+
},
|
|
81
|
+
'llm.completion': {
|
|
82
|
+
kind: 'span',
|
|
83
|
+
origin: 'server',
|
|
84
|
+
subjects: ['org'],
|
|
85
|
+
attrs: z.object({ gen_ai_request_model: z.string(), feature: z.string() }),
|
|
86
|
+
metrics: z.object({ tokens_in: z.number(), tokens_out: z.number(), cost_usd: z.number() }),
|
|
87
|
+
data: boundedMeta(),
|
|
88
|
+
indexedMetrics: ['cost_usd'],
|
|
89
|
+
retentionDays: 400,
|
|
90
|
+
durable: true,
|
|
91
|
+
rollups: [{
|
|
92
|
+
as: 'llm_cost',
|
|
93
|
+
by: ['attr:gen_ai_request_model', 'attr:feature'],
|
|
94
|
+
bucket: 'day',
|
|
95
|
+
sum: ['cost_usd'],
|
|
96
|
+
dimDefault: 'none',
|
|
97
|
+
}],
|
|
98
|
+
description: 'Single model call',
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
declare const mongooseish: CreateTelemetryConfig['connection'];
|
|
103
|
+
|
|
104
|
+
const t = createTelemetry({
|
|
105
|
+
registry,
|
|
106
|
+
connection: mongooseish,
|
|
107
|
+
pepper: 'p',
|
|
108
|
+
platforms: ['watchos'], // EXTENDS the builtins; 'web' still validates
|
|
109
|
+
bodyMax: 4096,
|
|
110
|
+
globalSubjectRefs: true, // refs name one party in every tenant — forget() reaches '*' views
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── emit is typed against the registry ──
|
|
114
|
+
async function writes() {
|
|
115
|
+
await t.emit('user.signed_up', {
|
|
116
|
+
tenantId: 'acc_9',
|
|
117
|
+
subjects: [{ type: 'user', id: 'u_1' }, { type: 'org', id: 'o_9' }],
|
|
118
|
+
actor: 'user:u_1',
|
|
119
|
+
attrs: { source: 'ads', plan: 'pro' },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// the durability contract is in the return type — 'written' vs 'queued'
|
|
123
|
+
const queued: EmitResult = await t.emit('llm.completion', {
|
|
124
|
+
tenantId: 'acc_9',
|
|
125
|
+
subjects: [{ type: 'org', id: 'o_9' }],
|
|
126
|
+
traceId: 'tr_1', spanId: 's_1', durationMs: 1900,
|
|
127
|
+
attrs: { gen_ai_request_model: 'claude-opus-5', feature: 'chat' },
|
|
128
|
+
metrics: { tokens_in: 1, tokens_out: 1, cost_usd: 0.04 },
|
|
129
|
+
});
|
|
130
|
+
const correlationId: string = queued.id;
|
|
131
|
+
if (queued.outcome === 'deduped') void correlationId;
|
|
132
|
+
|
|
133
|
+
// idempotent + awaited, per call
|
|
134
|
+
const { outcome } = await t.emit('user.signed_up', {
|
|
135
|
+
tenantId: 'acc_9',
|
|
136
|
+
subjects: [{ type: 'user', id: 'u_1' }, { type: 'org', id: 'o_9' }],
|
|
137
|
+
attrs: { source: 'ads', plan: 'pro' },
|
|
138
|
+
dedupeKey: 'stripe:evt_123',
|
|
139
|
+
durable: true,
|
|
140
|
+
});
|
|
141
|
+
const outcomes: EmitResult['outcome'][] =
|
|
142
|
+
['written', 'queued', 'deduped', 'sampled', 'capped', 'rejected'];
|
|
143
|
+
void outcomes.includes(outcome);
|
|
144
|
+
|
|
145
|
+
// the platform union stays open — builtins autocomplete, host additions compile
|
|
146
|
+
const client: ClientContext = { platform: 'watchos', appVersion: '1.0.0' };
|
|
147
|
+
const builtin: ClientContext = { platform: 'web', appVersion: '1.0.0' };
|
|
148
|
+
void client, builtin;
|
|
149
|
+
|
|
150
|
+
// @ts-expect-error — unknown event name is a compile error, not a silent drop
|
|
151
|
+
await t.emit('user.typo', { tenantId: 'acc_9' });
|
|
152
|
+
|
|
153
|
+
await t.emit('user.signed_up', {
|
|
154
|
+
tenantId: 'acc_9',
|
|
155
|
+
// @ts-expect-error — attrs are typed per event; `plan: 'gold'` is not in the enum
|
|
156
|
+
attrs: { source: 'ads', plan: 'gold' },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── the rest of the surface ──
|
|
161
|
+
async function reads() {
|
|
162
|
+
const s: Scoped = t.scoped('acc_9');
|
|
163
|
+
s.find({ subjectKeys: 'user:u_1' });
|
|
164
|
+
s.aggregate([{ $match: { kind: 'span' } }]);
|
|
165
|
+
s.rollups({ as: 'llm_cost' });
|
|
166
|
+
s.rollupAggregate([{ $group: { _id: '$dims' } }]);
|
|
167
|
+
|
|
168
|
+
// scoped() takes a tenantId and only a tenantId — PLATFORM_SCOPE is not
|
|
169
|
+
// special here, it is just a string that matches no row
|
|
170
|
+
const literal: Scoped = t.scoped(PLATFORM_SCOPE);
|
|
171
|
+
literal.find();
|
|
172
|
+
const platform: boolean = isPlatformScope(PLATFORM_SCOPE);
|
|
173
|
+
const notPlatform: boolean = isPlatformScope('acc_9');
|
|
174
|
+
void (platform && notPlatform);
|
|
175
|
+
|
|
176
|
+
const gone: ForgetResult = await t.forget('acc_9', 'user:u_1');
|
|
177
|
+
void (gone.deleted + gone.redacted + gone.rollups + gone.aliases);
|
|
178
|
+
|
|
179
|
+
const cp: Checkpoint = t.checkpoint('mailery-bridge');
|
|
180
|
+
const mark: Date | null = await cp.get();
|
|
181
|
+
await cp.advance(mark ?? new Date());
|
|
182
|
+
|
|
183
|
+
await t.syncIndexes();
|
|
184
|
+
await t.flush();
|
|
185
|
+
|
|
186
|
+
const c: TelemetryCounters = t.counters;
|
|
187
|
+
void (c.rejected + c.defaulted + c.sampled + c.capped + c.rollupSkipped + c.deduped + c.truncated);
|
|
188
|
+
|
|
189
|
+
t.models.telemetry.find();
|
|
190
|
+
t.models.byKind.usage.countDocuments();
|
|
191
|
+
t.models.rollups.aggregate([]);
|
|
192
|
+
t.models.checkpoints.findOne();
|
|
193
|
+
t.collections.rejects().countDocuments();
|
|
194
|
+
t.collections.aliases().deleteMany({});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── helpers keep their contracts ──
|
|
198
|
+
const id: string = newId();
|
|
199
|
+
const kept: boolean = traceKeep('tr_00ff', 0.5);
|
|
200
|
+
const day: Date | undefined = truncate(new Date(), 'day');
|
|
201
|
+
const obj: unknown = plain(new Map());
|
|
202
|
+
/** the rollup dimension resolver, exported so burst keys and host state agree */
|
|
203
|
+
const resolved: unknown = resolveDim('attr:source', { attrs: new Map([['source', 'ads']]) });
|
|
204
|
+
const noSubjectDim: unknown = resolveDim('subject', {});
|
|
205
|
+
void resolved, noSubjectDim;
|
|
206
|
+
validateRegistry(registry);
|
|
207
|
+
const budget: number = INDEX_BUDGET;
|
|
208
|
+
const bodyCap: number = BODY_MAX_CHARS;
|
|
209
|
+
const spanDays: number | null = RETENTION_DAYS.span;
|
|
210
|
+
const rate: number = SAMPLE_RATE.usage;
|
|
211
|
+
const v: number = SCHEMA_VERSION;
|
|
212
|
+
|
|
213
|
+
// ── the vocabulary, in BOTH positions ──
|
|
214
|
+
// Each of these ships as a `const` object AND a type. A host writing
|
|
215
|
+
// `TelemetryKind.Usage` must compile; so must `const k: TelemetryKind`. Reading
|
|
216
|
+
// the member off the object is what proves the value exists — an `import type`
|
|
217
|
+
// of the same name proves nothing, which is how these six drifted.
|
|
218
|
+
const usageKind: TelemetryKind = TelemetryKind.Usage;
|
|
219
|
+
const everyKind: TelemetryKind[] = [
|
|
220
|
+
TelemetryKind.Event, TelemetryKind.Error, TelemetryKind.Span,
|
|
221
|
+
TelemetryKind.State, TelemetryKind.Usage,
|
|
222
|
+
];
|
|
223
|
+
const fatal: LogLevel = LogLevel.Fatal;
|
|
224
|
+
const everyLevel: LogLevel[] = [LogLevel.Debug, LogLevel.Info, LogLevel.Warn, LogLevel.Error, LogLevel.Fatal];
|
|
225
|
+
const prod: Env = Env.Prod;
|
|
226
|
+
const everyEnv: Env[] = [Env.Prod, Env.Staging, Env.Dev];
|
|
227
|
+
const server: Origin = Origin.Server;
|
|
228
|
+
const everyOrigin: Origin[] = [Origin.Server, Origin.Client];
|
|
229
|
+
// the literal form keeps working — the const is an addition, never a narrowing
|
|
230
|
+
const literalKind: TelemetryKind = 'span';
|
|
231
|
+
const literalLevel: LogLevel = 'warn';
|
|
232
|
+
const literalEnv: Env = 'dev';
|
|
233
|
+
const literalOrigin: Origin = 'client';
|
|
234
|
+
// and the objects index the Records the package exports
|
|
235
|
+
const kindRetention: number | null = RETENTION_DAYS[TelemetryKind.Usage];
|
|
236
|
+
const kindRate: number = SAMPLE_RATE[TelemetryKind.Error];
|
|
237
|
+
void usageKind, everyKind, fatal, everyLevel, prod, everyEnv, server, everyOrigin;
|
|
238
|
+
void literalKind, literalLevel, literalEnv, literalOrigin, kindRetention, kindRate;
|
|
239
|
+
|
|
240
|
+
// the envelope base and its parts, standalone
|
|
241
|
+
const subject: SubjectInput = { type: 'user', id: 'u_1', role: 'sender' };
|
|
242
|
+
const base: EmitBase = {
|
|
243
|
+
tenantId: 'acc_9',
|
|
244
|
+
subjects: [subject],
|
|
245
|
+
actor: 'user:u_1',
|
|
246
|
+
onBehalfOf: 'system:cron',
|
|
247
|
+
occurredAt: new Date(),
|
|
248
|
+
severity: LogLevel.Warn,
|
|
249
|
+
service: 'api',
|
|
250
|
+
release: 'app@1.0.0',
|
|
251
|
+
env: Env.Staging,
|
|
252
|
+
origin: Origin.Server,
|
|
253
|
+
traceId: 'tr_1', spanId: 's_1', parentId: 'p_1', durationMs: 12,
|
|
254
|
+
data: { note: 'x' },
|
|
255
|
+
body: 'prose',
|
|
256
|
+
forceKeep: true,
|
|
257
|
+
dedupeKey: 'stripe:evt_1',
|
|
258
|
+
durable: true,
|
|
259
|
+
error: { type: 'TypeError', message: 'x', handled: false, fingerprint: 'fp', frames: [{ fn: 'f', inApp: true }] },
|
|
260
|
+
state: { key: 'lifecycle', from: 'trial', to: 'active', previousSinceMs: 10 },
|
|
261
|
+
usage: {
|
|
262
|
+
meter: 'tokens', quantity: 1, unit: 'token', amount: '0.04', currency: 'USD',
|
|
263
|
+
idempotencyKey: 'tr_1:s_1', billedTo: 'org:o_9', billable: true,
|
|
264
|
+
priceVersion: 'v1', reverses: 'rec_1',
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
void base;
|
|
268
|
+
|
|
269
|
+
// the per-event payload projections the typed emit is built from
|
|
270
|
+
const signupAttrs: AttrsOf<typeof registry, 'user.signed_up'> = { source: 'ads', plan: 'pro' };
|
|
271
|
+
const llmMetrics: MetricsOf<typeof registry, 'llm.completion'> = { tokens_in: 1, tokens_out: 1, cost_usd: 0.04 };
|
|
272
|
+
void signupAttrs, llmMetrics;
|
|
273
|
+
|
|
274
|
+
// ── keys + ingest surface ──
|
|
275
|
+
import type {
|
|
276
|
+
ContextAdapter,
|
|
277
|
+
CreateIngestOptions,
|
|
278
|
+
CreateKeyInput,
|
|
279
|
+
IngestContext,
|
|
280
|
+
ParsedKey,
|
|
281
|
+
} from './index.js';
|
|
282
|
+
import {
|
|
283
|
+
createIngest, createKey, hashSecret, parseKeyString, verifySecret, KeyKind, TenantMode,
|
|
284
|
+
} from './index.js';
|
|
285
|
+
|
|
286
|
+
async function keys() {
|
|
287
|
+
const minted = await t.createKey({
|
|
288
|
+
kind: 'publishable',
|
|
289
|
+
tenantMode: 'fixed',
|
|
290
|
+
tenantId: 'acc_9',
|
|
291
|
+
service: 'web',
|
|
292
|
+
env: 'prod',
|
|
293
|
+
origins: ['https://app.example.com'],
|
|
294
|
+
});
|
|
295
|
+
const full: string = minted.key;
|
|
296
|
+
const parsed: ParsedKey | null = parseKeyString(full);
|
|
297
|
+
const kk: KeyKind = parsed!.kind;
|
|
298
|
+
// both positions again — const object AND union, for the two key enums too
|
|
299
|
+
const publishable: KeyKind = KeyKind.Publishable;
|
|
300
|
+
const secret: KeyKind = KeyKind.Secret;
|
|
301
|
+
const tm: TenantMode = 'claimed';
|
|
302
|
+
const modes: TenantMode[] = [TenantMode.Fixed, TenantMode.Session, TenantMode.Claimed];
|
|
303
|
+
const ok: boolean = verifySecret('s', hashSecret('s'));
|
|
304
|
+
await createKey(t.models.keys, {
|
|
305
|
+
kind: KeyKind.Secret, tenantMode: TenantMode.Claimed, service: 'api', env: 'prod',
|
|
306
|
+
label: 'live', origins: [], allowedKinds: ['usage'], allowedNames: ['llm.completion'],
|
|
307
|
+
maxPerMinute: 600,
|
|
308
|
+
} satisfies CreateKeyInput);
|
|
309
|
+
void kk, publishable, secret, tm, modes, ok;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const adapter: ContextAdapter = {
|
|
313
|
+
resolveContext: () => ({ tenantId: 'acc_9', subjects: [{ type: 'user', id: 'u_1' }], actor: 'user:u_1' } satisfies IngestContext),
|
|
314
|
+
};
|
|
315
|
+
const ingestOpts: CreateIngestOptions = { telemetry: t, contextAdapter: adapter, maxRecords: 50 };
|
|
316
|
+
const ingestRouter = createIngest(ingestOpts);
|
|
317
|
+
|
|
318
|
+
// ── client core (types/core.d.ts) ──
|
|
319
|
+
import type {
|
|
320
|
+
ClientContextInput,
|
|
321
|
+
CreateClientOptions,
|
|
322
|
+
ClientStorage,
|
|
323
|
+
DimSource as CoreDimSource,
|
|
324
|
+
EventSpec as CoreEventSpec,
|
|
325
|
+
Registry as CoreRegistry,
|
|
326
|
+
RollupSpec as CoreRollupSpec,
|
|
327
|
+
Span as CoreSpan,
|
|
328
|
+
TelemetryClient as CoreClient,
|
|
329
|
+
TrackOptions,
|
|
330
|
+
Transport,
|
|
331
|
+
TransportResult,
|
|
332
|
+
WireRecord,
|
|
333
|
+
} from './core.js';
|
|
334
|
+
import {
|
|
335
|
+
createClient as createCoreClient,
|
|
336
|
+
newId as coreNewId,
|
|
337
|
+
defineRegistry as coreDefineRegistry,
|
|
338
|
+
boundedMeta as coreBoundedMeta,
|
|
339
|
+
} from './core.js';
|
|
340
|
+
|
|
341
|
+
// /core re-exports the isomorphic registry surface so a host's registry module
|
|
342
|
+
// imports from here and stays mongoose-free
|
|
343
|
+
const coreReg: CoreRegistry = coreDefineRegistry({
|
|
344
|
+
'a.b': {
|
|
345
|
+
kind: TelemetryKind.Event,
|
|
346
|
+
origin: Origin.Client,
|
|
347
|
+
subjects: [],
|
|
348
|
+
data: coreBoundedMeta(),
|
|
349
|
+
description: 'x',
|
|
350
|
+
} satisfies CoreEventSpec,
|
|
351
|
+
});
|
|
352
|
+
const coreRoll: CoreRollupSpec = { by: ['attr:source' satisfies CoreDimSource], bucket: 'day' };
|
|
353
|
+
const coreId: string = coreNewId();
|
|
354
|
+
void coreReg, coreRoll, coreId;
|
|
355
|
+
|
|
356
|
+
const transport: Transport = async () => ({ ok: true } satisfies TransportResult);
|
|
357
|
+
const storage: ClientStorage = { get: () => null, set: () => {} };
|
|
358
|
+
const ctxInput: ClientContextInput = { platform: 'web', appVersion: '1.0.0', online: true };
|
|
359
|
+
const clientOpts: CreateClientOptions = {
|
|
360
|
+
key: 'pk_live_tk_000000000000000000000000',
|
|
361
|
+
url: 'https://app.example.com/telemetry/ingest',
|
|
362
|
+
release: 'app@1.0.0',
|
|
363
|
+
flushIntervalMs: 5_000,
|
|
364
|
+
maxBatchSize: 50,
|
|
365
|
+
maxQueueSize: 1_000,
|
|
366
|
+
maxRetries: 5,
|
|
367
|
+
transport,
|
|
368
|
+
storage,
|
|
369
|
+
clientContext: ctxInput,
|
|
370
|
+
consent: () => true,
|
|
371
|
+
errorName: 'error.unhandled',
|
|
372
|
+
onError: () => {},
|
|
373
|
+
};
|
|
374
|
+
const trackOpts: TrackOptions<{ source: string }, { n: number }> = {
|
|
375
|
+
attrs: { source: 'ads' },
|
|
376
|
+
metrics: { n: 1 },
|
|
377
|
+
data: {},
|
|
378
|
+
occurredAt: new Date(),
|
|
379
|
+
subjects: [{ type: 'user', id: 'u_1' }],
|
|
380
|
+
severity: LogLevel.Info,
|
|
381
|
+
};
|
|
382
|
+
void clientOpts, trackOpts;
|
|
383
|
+
const c: CoreClient<typeof registry> = createCoreClient<typeof registry>({
|
|
384
|
+
key: 'pk_live_tk_000000000000000000000000',
|
|
385
|
+
url: 'https://app.example.com/telemetry/ingest',
|
|
386
|
+
release: 'app@1.0.0',
|
|
387
|
+
transport,
|
|
388
|
+
});
|
|
389
|
+
c.track('user.signed_up', { attrs: { source: 'ads', plan: 'pro' } });
|
|
390
|
+
// @ts-expect-error — typo'd names are compile errors in clients too
|
|
391
|
+
c.track('user.typo');
|
|
392
|
+
c.identify({ user: 'u_1', org: 'o_9' });
|
|
393
|
+
c.captureError(new Error('x'), { handled: false });
|
|
394
|
+
const span: CoreSpan = c.startSpan('pdf.render');
|
|
395
|
+
const traced: string = span.traceId;
|
|
396
|
+
const spanned: string = span.spanId;
|
|
397
|
+
span.end({ metrics: { bytes: 100 } });
|
|
398
|
+
void traced, spanned;
|
|
399
|
+
c.state('account.lifecycle', { key: 'lifecycle', to: 'active' });
|
|
400
|
+
c.setActor('user:u_1');
|
|
401
|
+
c.setActor(undefined);
|
|
402
|
+
const rec: WireRecord = { _id: 'x'.repeat(16), name: 'a', occurredAt: new Date().toISOString() };
|
|
403
|
+
// `_internal` exists on every shipped client — the platform adapters use it —
|
|
404
|
+
// so the declaration says so, opaquely. Reading a field off it must be a cast,
|
|
405
|
+
// which is the whole point of typing it `unknown`.
|
|
406
|
+
const internals: unknown = c._internal;
|
|
407
|
+
// @ts-expect-error — opaque on purpose: no member of `_internal` is contract
|
|
408
|
+
void c._internal.queue;
|
|
409
|
+
void internals;
|
|
410
|
+
async function drain() {
|
|
411
|
+
await c.flush();
|
|
412
|
+
await c.shutdown();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── dashboard surface ──
|
|
416
|
+
import type {
|
|
417
|
+
CohortSubject,
|
|
418
|
+
CreateDashboardOptions,
|
|
419
|
+
FunnelCohortWindow,
|
|
420
|
+
FunnelExitResult,
|
|
421
|
+
FunnelParams,
|
|
422
|
+
FunnelResult,
|
|
423
|
+
FunnelSlice,
|
|
424
|
+
FunnelStageResult,
|
|
425
|
+
FunnelStageSpec,
|
|
426
|
+
Queries,
|
|
427
|
+
QueryLimits,
|
|
428
|
+
RecordFilter,
|
|
429
|
+
ResolvedView,
|
|
430
|
+
SubjectAdapter,
|
|
431
|
+
TimeRange,
|
|
432
|
+
Viewer,
|
|
433
|
+
ViewerAdapter,
|
|
434
|
+
ViewSpec,
|
|
435
|
+
} from './index.js';
|
|
436
|
+
import {
|
|
437
|
+
createDashboard, createQueries, defaultSpaDir, deriveViews, findFamily,
|
|
438
|
+
median, requireMilestoneFamily, summarizeStages, DEFAULT_LIMITS,
|
|
439
|
+
} from './index.js';
|
|
440
|
+
|
|
441
|
+
const viewer: Viewer = { tenantId: 'acc_9', role: 'admin', viewerRef: 'user:u_1' };
|
|
442
|
+
/** the platform viewer — the host authorized it, the package only expresses it */
|
|
443
|
+
const platformViewer: Viewer = { tenantId: PLATFORM_SCOPE, role: 'admin', viewerRef: 'user:u_ops' };
|
|
444
|
+
const viewerAdapter: ViewerAdapter = {
|
|
445
|
+
resolveViewer: () => (isPlatformScope(viewer.tenantId) ? platformViewer : viewer),
|
|
446
|
+
};
|
|
447
|
+
const subjectAdapter: SubjectAdapter = {
|
|
448
|
+
describe: async (refs) => Object.fromEntries(refs.map((r) => [r, { label: r }])),
|
|
449
|
+
};
|
|
450
|
+
const view: ViewSpec = {
|
|
451
|
+
name: 'Checkout errors',
|
|
452
|
+
// the sidebar renders this when present and falls back to the origin badge
|
|
453
|
+
icon: '⚑',
|
|
454
|
+
page: 'errors',
|
|
455
|
+
query: { range: '24h', filters: { severity: 'error' }, display: 'table' },
|
|
456
|
+
};
|
|
457
|
+
const dashOpts: CreateDashboardOptions = {
|
|
458
|
+
telemetry: t,
|
|
459
|
+
viewerAdapter,
|
|
460
|
+
subjectAdapter,
|
|
461
|
+
views: [view],
|
|
462
|
+
queryLimits: { records: 100 },
|
|
463
|
+
onSlowQuery: ({ op, ms }) => void `${op}:${ms}`,
|
|
464
|
+
// the threshold the callback fires above, and the query cache — all three
|
|
465
|
+
// forwarded to createQueries, none of them reachable before
|
|
466
|
+
slowMs: 250,
|
|
467
|
+
cacheTtlMs: 30_000,
|
|
468
|
+
cacheSize: 200,
|
|
469
|
+
mountPath: '/telemetry',
|
|
470
|
+
apiBase: '/telemetry',
|
|
471
|
+
title: 'Telemetry',
|
|
472
|
+
spaDir: '/srv/ui',
|
|
473
|
+
};
|
|
474
|
+
const dashRouter = createDashboard(dashOpts);
|
|
475
|
+
const spa: string = defaultSpaDir();
|
|
476
|
+
const derived: ResolvedView[] = deriveViews(registry);
|
|
477
|
+
const caps: QueryLimits = DEFAULT_LIMITS;
|
|
478
|
+
|
|
479
|
+
async function primitives() {
|
|
480
|
+
const q: Queries = createQueries({
|
|
481
|
+
TelemetryModel: t.models.telemetry,
|
|
482
|
+
RollupModel: t.models.rollups,
|
|
483
|
+
registry,
|
|
484
|
+
limits: { records: 100 },
|
|
485
|
+
onSlowQuery: ({ op, ms, params }) => void `${op}:${ms}:${String(params)}`,
|
|
486
|
+
slowMs: 250,
|
|
487
|
+
// the in-process cache is tunable — a ten-minute TTL is a default, not a law
|
|
488
|
+
cacheTtlMs: 30_000,
|
|
489
|
+
cacheSize: 200,
|
|
490
|
+
});
|
|
491
|
+
const range: TimeRange = { from: new Date(0), to: new Date() };
|
|
492
|
+
const f: RecordFilter = { kind: 'span', excludeActorTypes: ['admin'] };
|
|
493
|
+
const page = await q.records('acc_9', range, f, { limit: 50 });
|
|
494
|
+
const next: string | null = page.nextCursor;
|
|
495
|
+
const ser = await q.series('acc_9', range, f, { measure: 'sum:cost_usd', interval: 'day' });
|
|
496
|
+
void ser.buckets[0]?.value;
|
|
497
|
+
// the sample is complete; the computation is capped, and says so
|
|
498
|
+
const dist = await q.distribution('acc_9', range, f);
|
|
499
|
+
const scanCut: boolean = dist.truncated;
|
|
500
|
+
void scanCut;
|
|
501
|
+
void caps.distribution;
|
|
502
|
+
const ro = await q.rollups('acc_9', { as: 'llm_cost', sort: 'bucketAt' });
|
|
503
|
+
const src: 'rollups' = ro.dataSource;
|
|
504
|
+
const short: boolean = ro.truncated;
|
|
505
|
+
// the multi-subject read and the explicit cohort field, both new
|
|
506
|
+
await q.rollups('acc_9', { as: 'user.signed_up', dims: ['user:u_1', 'user:u_2'], on: 'firstAt', range });
|
|
507
|
+
await q.trace('acc_9', 'tr_1');
|
|
508
|
+
await q.journey('acc_9', 'user:u_1', range);
|
|
509
|
+
void short;
|
|
510
|
+
|
|
511
|
+
// ── the two cohort primitives ──
|
|
512
|
+
const dau = await q.distinctCount('acc_9', { as: 'activity', range, interval: 'day' });
|
|
513
|
+
const distinct: number = dau.distinct;
|
|
514
|
+
const grain: 'hour' | 'day' | 'week' | 'month' = dau.interval;
|
|
515
|
+
const perBucket: number = dau.buckets[0]?.value ?? 0;
|
|
516
|
+
void (distinct + perBucket), grain, dau.truncated;
|
|
517
|
+
|
|
518
|
+
const funnelParams: FunnelParams = {
|
|
519
|
+
stages: [{ as: 'user.signed_up' }, { as: 'activated', label: 'Activated' }],
|
|
520
|
+
anchor: 'user.signed_up',
|
|
521
|
+
// half-open by default; the closed form maxed uses is opt-in and named
|
|
522
|
+
cohort: { from: range.from, to: range.to, endInclusive: true } satisfies FunnelCohortWindow,
|
|
523
|
+
exits: [{ as: 'churned' }],
|
|
524
|
+
subjectType: 'user',
|
|
525
|
+
interval: 'week',
|
|
526
|
+
limit: 1_000,
|
|
527
|
+
};
|
|
528
|
+
const fun: FunnelResult = await q.funnel('acc_9', funnelParams);
|
|
529
|
+
const stage: FunnelStageResult = fun.stages[0]!;
|
|
530
|
+
const pctPrev: number | null = stage.pctOfPrevious;
|
|
531
|
+
const fromAnchor: number | null = stage.medianDaysFromAnchor;
|
|
532
|
+
const missed: number = stage.notReached;
|
|
533
|
+
const stalled: number | null = stage.stalledAt; // null on the terminal stage
|
|
534
|
+
const exit: FunnelExitResult = fun.exits[0]!;
|
|
535
|
+
const slice: FunnelSlice | undefined = fun.slices?.[0];
|
|
536
|
+
void (fun.cohortSubjects + fun.first + exit.subjects + missed);
|
|
537
|
+
void pctPrev, fromAnchor, stalled, slice?.at, fun.truncated, fun.cohort.anchor, fun.dataSource;
|
|
538
|
+
|
|
539
|
+
// every primitive takes the platform scope in the same position a tenantId
|
|
540
|
+
// goes — that IS the API: one argument, two meanings, no second entry point
|
|
541
|
+
await q.records(PLATFORM_SCOPE, range, f);
|
|
542
|
+
await q.series(PLATFORM_SCOPE, range, f);
|
|
543
|
+
await q.distribution(PLATFORM_SCOPE, range, f);
|
|
544
|
+
await q.rollups(PLATFORM_SCOPE, { as: 'llm_cost' });
|
|
545
|
+
await q.trace(PLATFORM_SCOPE, 'tr_1');
|
|
546
|
+
await q.journey(PLATFORM_SCOPE, 'user:u_1', range);
|
|
547
|
+
await q.distinctCount(PLATFORM_SCOPE, { as: 'activity', range });
|
|
548
|
+
await q.funnel(PLATFORM_SCOPE, { stages: [{ as: 'user.signed_up' }], cohort: range });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── the funnel math, usable without a database ──
|
|
552
|
+
const mid: number | null = median([3, 3, 4, 5]); // 3.5 — mean of the two middles
|
|
553
|
+
const empty: number | null = median([]); // null, never 0
|
|
554
|
+
const cohortIndex: CohortSubject[] = [
|
|
555
|
+
{ ref: 'user:u_1', anchorAt: new Date(), stages: { signed_up: new Date() }, exits: {} },
|
|
556
|
+
{ ref: 'user:u_2', anchorAt: null, stages: {}, exits: { churned: new Date() } },
|
|
557
|
+
];
|
|
558
|
+
const stageSpec: FunnelStageSpec = { as: 'user.signed_up', key: 'signed_up', label: 'Signed up' };
|
|
559
|
+
const table: FunnelStageResult[] = summarizeStages(cohortIndex, [
|
|
560
|
+
{ order: 1, key: stageSpec.key!, as: stageSpec.as, label: stageSpec.label! },
|
|
561
|
+
]);
|
|
562
|
+
const family = findFamily(registry, 'llm_cost');
|
|
563
|
+
const milestoneSpec: RollupSpec = requireMilestoneFamily(registry, 'user.signed_up', 'funnel()');
|
|
564
|
+
void mid, empty, table[0]?.subjects, family?.spec, family?.name, milestoneSpec.by;
|
|
565
|
+
|
|
566
|
+
// forget() now reports views too
|
|
567
|
+
async function forgetViews() {
|
|
568
|
+
const gone = await t.forget('acc_9', 'user:u_1');
|
|
569
|
+
const v: number = gone.views;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── shapes are exported and usable standalone ──
|
|
573
|
+
const dim: DimSource = 'attr:source';
|
|
574
|
+
const roll: RollupSpec = { by: [dim], bucket: 'week', actors: ['user'], dimDefault: 'unset' };
|
|
575
|
+
const spec: EventSpec = { kind: 'event', origin: 'any', subjects: [], durable: true, description: 'x' };
|
|
576
|
+
const reg: Registry = { 'a.b': spec };
|
|
577
|
+
const ref: EntityRef = 'user:u_1';
|
|
578
|
+
const kind: TelemetryKind = 'usage';
|
|
579
|
+
const log: Logger = { info() {}, warn() {}, error() {} };
|
|
580
|
+
declare const generic: Telemetry;
|
|
581
|
+
const input: EmitInput<typeof registry, 'user.signed_up'> = {
|
|
582
|
+
tenantId: 'a',
|
|
583
|
+
attrs: { source: 's', plan: 'free' },
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// ── the platform entry points ──
|
|
587
|
+
// Six subpaths, six declaration files, and until now this file compiled none of
|
|
588
|
+
// them. Each entry's FULL export list is exercised below — every re-exported
|
|
589
|
+
// `createClient` under its own alias, every const as a value, every factory
|
|
590
|
+
// called. A subpath whose exports are not walked here is a subpath that can
|
|
591
|
+
// drift without the gate noticing.
|
|
592
|
+
import type * as React from 'react';
|
|
593
|
+
|
|
594
|
+
import type { TelemetryClient as WebClient, WebTelemetryOptions } from './web.js';
|
|
595
|
+
import { createClient as createWebCoreClient, createWebTelemetry } from './web.js';
|
|
596
|
+
|
|
597
|
+
import type { TelemetryClient as ReactClient } from './react.js';
|
|
598
|
+
import {
|
|
599
|
+
createClient as createReactCoreClient,
|
|
600
|
+
TelemetryProvider,
|
|
601
|
+
TelemetryErrorBoundary,
|
|
602
|
+
useTelemetry as useReactTelemetry,
|
|
603
|
+
} from './react.js';
|
|
604
|
+
|
|
605
|
+
import type { TelemetryClient as VueClient } from './vue.js';
|
|
606
|
+
import {
|
|
607
|
+
createClient as createVueCoreClient,
|
|
608
|
+
createTelemetryPlugin,
|
|
609
|
+
useTelemetry as useVueTelemetry,
|
|
610
|
+
TELEMETRY_KEY,
|
|
611
|
+
} from './vue.js';
|
|
612
|
+
|
|
613
|
+
import type { MainTelemetryOptions, TelemetryClient as ElectronClient } from './electron.js';
|
|
614
|
+
import {
|
|
615
|
+
createClient as createElectronCoreClient,
|
|
616
|
+
createMainTelemetry,
|
|
617
|
+
createRendererTelemetry,
|
|
618
|
+
IPC_CHANNEL,
|
|
619
|
+
} from './electron.js';
|
|
620
|
+
|
|
621
|
+
import type { CliTelemetryOptions, TelemetryClient as CliClient } from './cli.js';
|
|
622
|
+
import { createClient as createCliCoreClient, createCliTelemetry } from './cli.js';
|
|
623
|
+
|
|
624
|
+
// every subpath re-exports the core factory — same shape, six import paths
|
|
625
|
+
const reexported: Array<typeof createCoreClient> = [
|
|
626
|
+
createWebCoreClient, createReactCoreClient, createVueCoreClient,
|
|
627
|
+
createElectronCoreClient, createCliCoreClient,
|
|
628
|
+
];
|
|
629
|
+
void reexported;
|
|
630
|
+
|
|
631
|
+
// /web — consent is re-added on top of the Omit, DNT/GPC still win
|
|
632
|
+
const webOpts: WebTelemetryOptions = {
|
|
633
|
+
key: 'pk_live_tk_000000000000000000000000',
|
|
634
|
+
url: '/telemetry/ingest',
|
|
635
|
+
release: 'app@1.0.0',
|
|
636
|
+
consent: () => true,
|
|
637
|
+
captureGlobalErrors: true,
|
|
638
|
+
};
|
|
639
|
+
const web: WebClient<typeof registry> = createWebTelemetry<typeof registry>(webOpts);
|
|
640
|
+
web.track('user.signed_up', { attrs: { source: 'ads', plan: 'pro' } });
|
|
641
|
+
|
|
642
|
+
// /react — the provider, the hook, the boundary
|
|
643
|
+
const plainClient: ReactClient = createReactCoreClient({ key: 'pk_x', url: '/i', transport });
|
|
644
|
+
const providerEl: React.ReactElement = TelemetryProvider({ client: plainClient });
|
|
645
|
+
const boundary = new TelemetryErrorBoundary({
|
|
646
|
+
client: plainClient,
|
|
647
|
+
fallback: (e: Error) => e.message,
|
|
648
|
+
});
|
|
649
|
+
const hooked: ReactClient = useReactTelemetry();
|
|
650
|
+
void providerEl, boundary, hooked;
|
|
651
|
+
|
|
652
|
+
// /vue — the injection key is a value, and the plugin installs onto an app
|
|
653
|
+
const vueClient: VueClient = createVueCoreClient({ key: 'pk_x', url: '/i', transport });
|
|
654
|
+
const key: 'telemetry' = TELEMETRY_KEY;
|
|
655
|
+
const plugin = createTelemetryPlugin(vueClient);
|
|
656
|
+
plugin.install({ config: { errorHandler: () => {} }, provide: () => {} });
|
|
657
|
+
const injected: VueClient = useVueTelemetry(() => vueClient);
|
|
658
|
+
void key, injected;
|
|
659
|
+
|
|
660
|
+
// /electron — main owns the only real queue, the renderer rides IPC
|
|
661
|
+
const channel: 'telemetry:batch' = IPC_CHANNEL;
|
|
662
|
+
const mainOpts: MainTelemetryOptions = {
|
|
663
|
+
key: 'sk_live_tk_000000000000000000000000',
|
|
664
|
+
url: 'https://app.example.com/telemetry/ingest',
|
|
665
|
+
captureProcessErrors: true,
|
|
666
|
+
ipcMain: { handle: () => {} },
|
|
667
|
+
};
|
|
668
|
+
const main: ElectronClient = createMainTelemetry(mainOpts);
|
|
669
|
+
const renderer: ElectronClient = createRendererTelemetry(
|
|
670
|
+
{ invoke: async () => ({ ok: true }) },
|
|
671
|
+
{ release: 'app@1.0.0' },
|
|
672
|
+
);
|
|
673
|
+
// key/url/transport are Omitted on the renderer — they never leave main
|
|
674
|
+
// @ts-expect-error — the renderer must not be handed a key
|
|
675
|
+
createRendererTelemetry({ invoke: async () => ({}) }, { key: 'sk_leak' });
|
|
676
|
+
void channel, main, renderer;
|
|
677
|
+
|
|
678
|
+
// /cli — disk-backed queue, opt-out honoured hard
|
|
679
|
+
const cliOpts: CliTelemetryOptions = {
|
|
680
|
+
key: 'sk_live_tk_000000000000000000000000',
|
|
681
|
+
url: 'https://app.example.com/telemetry/ingest',
|
|
682
|
+
configDir: '~/.config/mytool',
|
|
683
|
+
argv: ['node', 'mytool', '--no-telemetry'],
|
|
684
|
+
maxQueueAgeMs: 7 * 864e5,
|
|
685
|
+
};
|
|
686
|
+
const cli: CliClient = createCliTelemetry(cliOpts);
|
|
687
|
+
void cli;
|