@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.
@@ -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-Deqhu-hT.js"></script>
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.4.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());