@jeffjassky/telemetry 0.4.0 → 0.6.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/dist/index.cjs +319 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +317 -46
- package/dist/index.js.map +1 -1
- package/dist/mcp.cjs.map +1 -1
- package/dist/mcp.js.map +1 -1
- package/dist/ui/_assets/index-kvwB9_3A.js +41 -0
- package/dist/ui/_assets/{index-Deqhu-hT.js.map → index-kvwB9_3A.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/types/index.d.ts +162 -0
- package/types/test-d.ts +38 -0
- package/dist/ui/_assets/index-Deqhu-hT.js +0 -41
package/dist/ui/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Telemetry</title>
|
|
7
7
|
<!--telemetry-config-->
|
|
8
|
-
<script type="module" crossorigin src="./_assets/index-
|
|
8
|
+
<script type="module" crossorigin src="./_assets/index-kvwB9_3A.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="./_assets/index-COswHpSX.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeffjassky/telemetry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Unified telemetry — product events, errors, traces, state transitions, and billable usage in one Mongo envelope, with typed SDKs and a mountable dashboard.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jeff Jassky <jeff@jeffjassky.com>",
|
package/types/index.d.ts
CHANGED
|
@@ -632,6 +632,24 @@ export interface TelemetryCounters {
|
|
|
632
632
|
* not stripped; this groups what the quarantine lists one row at a time.
|
|
633
633
|
*/
|
|
634
634
|
undeclaredAttrs: Record<string, number>;
|
|
635
|
+
/**
|
|
636
|
+
* Write-time subject linking. All six stay at zero without a
|
|
637
|
+
* `subjectLinker`. They are split six ways because every way linking can fail
|
|
638
|
+
* ends in the same row — one written with the subjects it arrived with — and
|
|
639
|
+
* a silently unlinked record is indistinguishable from one nobody could link.
|
|
640
|
+
*/
|
|
641
|
+
/** subjects actually ADDED to records — two links on one record count twice */
|
|
642
|
+
subjectsLinked: number;
|
|
643
|
+
/** records where the linker answered `[]` — no link exists, which is an answer */
|
|
644
|
+
subjectLinkMisses: number;
|
|
645
|
+
/** the linker threw, rejected, or returned something that is not a list of refs */
|
|
646
|
+
subjectLinkErrors: number;
|
|
647
|
+
/** the linker outran `subjectLinkTimeoutMs`; the record was written unlinked */
|
|
648
|
+
subjectLinkTimeouts: number;
|
|
649
|
+
/** a linked subject whose `type` the event's `EventSpec.subjects` does not declare */
|
|
650
|
+
subjectLinkUndeclared: number;
|
|
651
|
+
/** a linked subject dropped because the record already held `SUBJECT_MAX` of them */
|
|
652
|
+
subjectLinkCapped: number;
|
|
635
653
|
}
|
|
636
654
|
|
|
637
655
|
/**
|
|
@@ -678,6 +696,59 @@ export interface ForgetResult {
|
|
|
678
696
|
views: number;
|
|
679
697
|
}
|
|
680
698
|
|
|
699
|
+
export interface RelinkOptions {
|
|
700
|
+
/**
|
|
701
|
+
* Restrict to these event names. Default: every stored record, whatever its
|
|
702
|
+
* name. A name the registry does not declare THROWS, before any I/O — a typo
|
|
703
|
+
* that silently relinks nothing looks exactly like a clean run.
|
|
704
|
+
*/
|
|
705
|
+
names?: string[];
|
|
706
|
+
/** only records at/after this instant (`occurredAt`) */
|
|
707
|
+
since?: Date;
|
|
708
|
+
/**
|
|
709
|
+
* Stop after this many records are EXAMINED — not linked. A budget for the
|
|
710
|
+
* scan, so a huge collection can be probed cheaply. Default unbounded.
|
|
711
|
+
*/
|
|
712
|
+
limit?: number;
|
|
713
|
+
/**
|
|
714
|
+
* Report what would change and write NOTHING. **Defaults to `true`**, because
|
|
715
|
+
* this rewrites historical aggregates and the short call has to be the safe
|
|
716
|
+
* one. A dry run still asks the host's linker, so the linking counters move;
|
|
717
|
+
* nothing on disk does.
|
|
718
|
+
*/
|
|
719
|
+
dryRun?: boolean;
|
|
720
|
+
/** records fetched per batch, and the `onProgress` cadence. Default 500. */
|
|
721
|
+
batchSize?: number;
|
|
722
|
+
/** cumulative counts after each batch, for progress output. Guarded: a
|
|
723
|
+
* printer that throws does not kill the backfill. */
|
|
724
|
+
onProgress?: (r: RelinkResult) => void;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export interface RelinkResult {
|
|
728
|
+
/** rows read */
|
|
729
|
+
examined: number;
|
|
730
|
+
/** rows that gained at least one subject */
|
|
731
|
+
linked: number;
|
|
732
|
+
/** subjects added in total — two links on one row count twice */
|
|
733
|
+
subjects: number;
|
|
734
|
+
/** rollup documents written, or under `dryRun` that would have been */
|
|
735
|
+
rollups: number;
|
|
736
|
+
/** rows where the linker answered `[]`. On a backfill this is the expected
|
|
737
|
+
* answer for most rows, and it is not a failure. */
|
|
738
|
+
misses: number;
|
|
739
|
+
/** rows where the linker threw, rejected, timed out or answered garbage —
|
|
740
|
+
* once per ROW however many ways it went wrong. `counters.subjectLinkErrors`
|
|
741
|
+
* and `subjectLinkTimeouts` keep the finer split. */
|
|
742
|
+
errors: number;
|
|
743
|
+
/** rows the run declined to offer the linker (a stored name the registry no
|
|
744
|
+
* longer declares, so its rollup families are unknowable), plus one standing
|
|
745
|
+
* for a call with no `subjectLinker` configured, which reads nothing. */
|
|
746
|
+
skipped: number;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** records fetched per batch by `relink()`, and how often `onProgress` fires */
|
|
750
|
+
export declare const RELINK_BATCH_SIZE: 500;
|
|
751
|
+
|
|
681
752
|
export interface Scoped {
|
|
682
753
|
find(q?: Record<string, unknown>): Query<any[], any>;
|
|
683
754
|
aggregate(stages: Record<string, unknown>[]): Aggregate<any[]>;
|
|
@@ -709,9 +780,73 @@ export interface CreateTelemetryConfig<R extends Registry = Registry> {
|
|
|
709
780
|
* different person in each, and one tenant's erasure would reach another's.
|
|
710
781
|
*/
|
|
711
782
|
globalSubjectRefs?: boolean;
|
|
783
|
+
/**
|
|
784
|
+
* Attach additional subjects to a record AT WRITE TIME — the desktop
|
|
785
|
+
* `machine:<installId>` the host can resolve to a `user`, joined once, onto
|
|
786
|
+
* the row and its rollups, rather than at every read that ever wants it.
|
|
787
|
+
* Must be cached: it runs once per record on the ingest path.
|
|
788
|
+
*/
|
|
789
|
+
subjectLinker?: SubjectLinker;
|
|
790
|
+
/**
|
|
791
|
+
* What `subjectLinker.link()` gets per record before the write proceeds
|
|
792
|
+
* UNLINKED and counts a timeout. Default 50.
|
|
793
|
+
*/
|
|
794
|
+
subjectLinkTimeoutMs?: number;
|
|
712
795
|
logger?: Logger;
|
|
713
796
|
}
|
|
714
797
|
|
|
798
|
+
/**
|
|
799
|
+
* WRITE-time subject linking — distinct from `SubjectAdapter`, which labels
|
|
800
|
+
* refs at read time and changes nothing about what is stored.
|
|
801
|
+
*
|
|
802
|
+
* A desktop client knows its install and nothing else, so its records carry
|
|
803
|
+
* `machine:<installId>` and no `user`. Resolving that at read time leaves a
|
|
804
|
+
* cohort funnel anchored on `user` reading zero for every desktop stage;
|
|
805
|
+
* resolving it at write time puts the party on the row AND on its rollups,
|
|
806
|
+
* which is the half a read-time join can never reach.
|
|
807
|
+
*/
|
|
808
|
+
export interface SubjectLinker {
|
|
809
|
+
/**
|
|
810
|
+
* Additional subjects for a record being written. `[]` when nothing links —
|
|
811
|
+
* that is an answer, and it is counted as one.
|
|
812
|
+
*
|
|
813
|
+
* Runs once per record on the write path, so it must answer from a cache.
|
|
814
|
+
* The package bounds it rather than trusting it: past `subjectLinkTimeoutMs`,
|
|
815
|
+
* or on a throw, the record is written unlinked and counted. A linked subject
|
|
816
|
+
* whose `type` the event does not declare is written ANYWAY and counted in
|
|
817
|
+
* `counters.subjectLinkUndeclared` — refusing it could only ever be obeyed by
|
|
818
|
+
* losing rows, because `EventSpec.subjects` is a required list.
|
|
819
|
+
*/
|
|
820
|
+
link(
|
|
821
|
+
subjects: SubjectInput[],
|
|
822
|
+
ctx: { name: string; tenantId: string },
|
|
823
|
+
): SubjectInput[] | Promise<SubjectInput[]>;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The guarded linker the instance resolved at construction: the merged subjects
|
|
828
|
+
* to write, or `null` when nothing changed. Exposed as `t.linkSubjects` for the
|
|
829
|
+
* router factories, which reach it the way they reach the registry.
|
|
830
|
+
*/
|
|
831
|
+
export type LinkSubjects = (
|
|
832
|
+
name: string,
|
|
833
|
+
spec: { subjects: readonly string[] },
|
|
834
|
+
tenantId: string,
|
|
835
|
+
declared: unknown,
|
|
836
|
+
) => Promise<SubjectInput[] | null>;
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Total subjects one record may carry once linking has run. `subjectKeys` is a
|
|
840
|
+
* multikey index term and every subject fans a `by:['subject']` rollup out one
|
|
841
|
+
* more time, so an unbounded array is unbounded write amplification with a
|
|
842
|
+
* host's cache bug behind it. Overflow is dropped and counted in
|
|
843
|
+
* `counters.subjectLinkCapped`.
|
|
844
|
+
*/
|
|
845
|
+
export declare const SUBJECT_MAX: 8;
|
|
846
|
+
|
|
847
|
+
/** default `subjectLinkTimeoutMs` — past it the record is written unlinked */
|
|
848
|
+
export declare const SUBJECT_LINK_TIMEOUT_MS: 50;
|
|
849
|
+
|
|
715
850
|
export interface Telemetry<R extends Registry = Registry> {
|
|
716
851
|
/** write — the only write. The result says what actually happened to the row. */
|
|
717
852
|
emit<N extends keyof R & string>(name: N, doc: EmitInput<R, N>): Promise<EmitResult>;
|
|
@@ -722,6 +857,25 @@ export interface Telemetry<R extends Registry = Registry> {
|
|
|
722
857
|
* platform-scoped saved views only when `globalSubjectRefs` is set.
|
|
723
858
|
*/
|
|
724
859
|
forget(tenantId: string, ref: EntityRef): Promise<ForgetResult>;
|
|
860
|
+
/**
|
|
861
|
+
* Backfill for `subjectLinker`: re-ask it about records ALREADY on disk, and
|
|
862
|
+
* replay the rollups the new subjects reach.
|
|
863
|
+
*
|
|
864
|
+
* Linking happens at write time, so configuring the hook fixes the future and
|
|
865
|
+
* nothing else — a lifetime `by:['subject']` family is keyed on the subject
|
|
866
|
+
* the record was written with, permanently, and no read-time join can reach
|
|
867
|
+
* back into it. A host that adopts linking on a Tuesday therefore has a
|
|
868
|
+
* backlog whose rows carry only `machine:<installId>` and whose user-keyed
|
|
869
|
+
* families have no member for any of them. This is how it catches up.
|
|
870
|
+
*
|
|
871
|
+
* **Dry run by default.** It rewrites historical aggregates, so `t.relink()`
|
|
872
|
+
* reports and `t.relink({ dryRun: false })` writes. Idempotent by
|
|
873
|
+
* construction: a row that already carries the linked subject yields nothing
|
|
874
|
+
* new, so a second run writes nothing and replays nothing. Returns
|
|
875
|
+
* `{ skipped: 1 }` rather than throwing when no `subjectLinker` is
|
|
876
|
+
* configured.
|
|
877
|
+
*/
|
|
878
|
+
relink(opts?: RelinkOptions): Promise<RelinkResult>;
|
|
725
879
|
/**
|
|
726
880
|
* Tenant scope is not optional — every read goes through here. Unconditional
|
|
727
881
|
* on purpose: it does not understand PLATFORM_SCOPE, so `scoped('*')` scopes
|
|
@@ -739,6 +893,14 @@ export interface Telemetry<R extends Registry = Registry> {
|
|
|
739
893
|
counters: TelemetryCounters;
|
|
740
894
|
/** the registry this instance validates against */
|
|
741
895
|
registry: R;
|
|
896
|
+
/**
|
|
897
|
+
* Write-time subject linking, guarded and resolved once at construction;
|
|
898
|
+
* `null` without a `subjectLinker`. Exposed for the router factories: the
|
|
899
|
+
* wire path does not go through `emit()` — at-least-once delivery inverts the
|
|
900
|
+
* plane order — so `createIngest` reaches the one implementation here rather
|
|
901
|
+
* than growing a second copy of the rules.
|
|
902
|
+
*/
|
|
903
|
+
linkSubjects: LinkSubjects | null;
|
|
742
904
|
logger: Logger;
|
|
743
905
|
/** mint an ingest key against this instance's key collection */
|
|
744
906
|
createKey(input: CreateKeyInput): Promise<{ key: string; id: string }>;
|
package/types/test-d.ts
CHANGED
|
@@ -33,9 +33,13 @@ import type {
|
|
|
33
33
|
Logger,
|
|
34
34
|
MetricsOf,
|
|
35
35
|
Registry,
|
|
36
|
+
RelinkOptions,
|
|
37
|
+
RelinkResult,
|
|
36
38
|
RollupSpec,
|
|
39
|
+
LinkSubjects,
|
|
37
40
|
Scoped,
|
|
38
41
|
SubjectInput,
|
|
42
|
+
SubjectLinker,
|
|
39
43
|
Telemetry,
|
|
40
44
|
TelemetryCounters,
|
|
41
45
|
} from './index.js';
|
|
@@ -58,9 +62,12 @@ import {
|
|
|
58
62
|
LogLevel,
|
|
59
63
|
Origin,
|
|
60
64
|
PLATFORM_SCOPE,
|
|
65
|
+
RELINK_BATCH_SIZE,
|
|
61
66
|
RETENTION_DAYS,
|
|
62
67
|
SAMPLE_RATE,
|
|
63
68
|
SCHEMA_VERSION,
|
|
69
|
+
SUBJECT_LINK_TIMEOUT_MS,
|
|
70
|
+
SUBJECT_MAX,
|
|
64
71
|
TelemetryKind,
|
|
65
72
|
} from './index.js';
|
|
66
73
|
|
|
@@ -101,6 +108,15 @@ const registry = defineRegistry({
|
|
|
101
108
|
|
|
102
109
|
declare const mongooseish: CreateTelemetryConfig['connection'];
|
|
103
110
|
|
|
111
|
+
/** WRITE-time: the desktop's machine ref, resolved to the account that owns it */
|
|
112
|
+
const subjectLinker: SubjectLinker = {
|
|
113
|
+
link: async (subjects, { name, tenantId }) => {
|
|
114
|
+
void name, tenantId;
|
|
115
|
+
const machine = subjects.find((s) => s.type === 'machine');
|
|
116
|
+
return machine ? [{ type: 'user', id: `u_${machine.id}`, role: 'owner' }] : [];
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
104
120
|
const t = createTelemetry({
|
|
105
121
|
registry,
|
|
106
122
|
connection: mongooseish,
|
|
@@ -108,8 +124,15 @@ const t = createTelemetry({
|
|
|
108
124
|
platforms: ['watchos'], // EXTENDS the builtins; 'web' still validates
|
|
109
125
|
bodyMax: 4096,
|
|
110
126
|
globalSubjectRefs: true, // refs name one party in every tenant — forget() reaches '*' views
|
|
127
|
+
subjectLinker,
|
|
128
|
+
subjectLinkTimeoutMs: SUBJECT_LINK_TIMEOUT_MS,
|
|
111
129
|
});
|
|
112
130
|
|
|
131
|
+
// the guarded linker the instance resolved, shared with the router factories
|
|
132
|
+
const linkSubjects: LinkSubjects | null = t.linkSubjects;
|
|
133
|
+
const subjectCap: number = SUBJECT_MAX;
|
|
134
|
+
void linkSubjects, subjectCap;
|
|
135
|
+
|
|
113
136
|
// ── emit is typed against the registry ──
|
|
114
137
|
async function writes() {
|
|
115
138
|
await t.emit('user.signed_up', {
|
|
@@ -176,6 +199,21 @@ async function reads() {
|
|
|
176
199
|
const gone: ForgetResult = await t.forget('acc_9', 'user:u_1');
|
|
177
200
|
void (gone.deleted + gone.redacted + gone.rollups + gone.aliases);
|
|
178
201
|
|
|
202
|
+
// relink() — the backfill for subjectLinker. Dry run by DEFAULT, so the
|
|
203
|
+
// no-argument call is the safe one and writing is opted into.
|
|
204
|
+
const preview: RelinkResult = await t.relink();
|
|
205
|
+
const opts: RelinkOptions = {
|
|
206
|
+
names: ['user.signed_up'],
|
|
207
|
+
since: new Date('2026-01-01T00:00:00Z'),
|
|
208
|
+
limit: 10_000,
|
|
209
|
+
dryRun: false,
|
|
210
|
+
batchSize: RELINK_BATCH_SIZE,
|
|
211
|
+
onProgress: (p) => void p.examined,
|
|
212
|
+
};
|
|
213
|
+
const done: RelinkResult = await t.relink(opts);
|
|
214
|
+
void (preview.examined + done.linked + done.subjects + done.rollups
|
|
215
|
+
+ done.misses + done.errors + done.skipped);
|
|
216
|
+
|
|
179
217
|
const cp: Checkpoint = t.checkpoint('mailery-bridge');
|
|
180
218
|
const mark: Date | null = await cp.get();
|
|
181
219
|
await cp.advance(mark ?? new Date());
|