@jeffjassky/telemetry 0.4.0 → 0.5.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.5.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
  /**
@@ -709,9 +727,71 @@ export interface CreateTelemetryConfig<R extends Registry = Registry> {
709
727
  * different person in each, and one tenant's erasure would reach another's.
710
728
  */
711
729
  globalSubjectRefs?: boolean;
730
+ /**
731
+ * Attach additional subjects to a record AT WRITE TIME — the desktop
732
+ * `machine:<installId>` the host can resolve to a `user`, joined once, onto
733
+ * the row and its rollups, rather than at every read that ever wants it.
734
+ * Must be cached: it runs once per record on the ingest path.
735
+ */
736
+ subjectLinker?: SubjectLinker;
737
+ /**
738
+ * What `subjectLinker.link()` gets per record before the write proceeds
739
+ * UNLINKED and counts a timeout. Default 50.
740
+ */
741
+ subjectLinkTimeoutMs?: number;
712
742
  logger?: Logger;
713
743
  }
714
744
 
745
+ /**
746
+ * WRITE-time subject linking — distinct from `SubjectAdapter`, which labels
747
+ * refs at read time and changes nothing about what is stored.
748
+ *
749
+ * A desktop client knows its install and nothing else, so its records carry
750
+ * `machine:<installId>` and no `user`. Resolving that at read time leaves a
751
+ * cohort funnel anchored on `user` reading zero for every desktop stage;
752
+ * resolving it at write time puts the party on the row AND on its rollups,
753
+ * which is the half a read-time join can never reach.
754
+ */
755
+ export interface SubjectLinker {
756
+ /**
757
+ * Additional subjects for a record being written. `[]` when nothing links —
758
+ * that is an answer, and it is counted as one.
759
+ *
760
+ * Runs once per record on the write path, so it must answer from a cache.
761
+ * The package bounds it rather than trusting it: past `subjectLinkTimeoutMs`,
762
+ * or on a throw, the record is written unlinked and counted. A linked subject
763
+ * whose `type` the event does not declare is refused, not written.
764
+ */
765
+ link(
766
+ subjects: SubjectInput[],
767
+ ctx: { name: string; tenantId: string },
768
+ ): SubjectInput[] | Promise<SubjectInput[]>;
769
+ }
770
+
771
+ /**
772
+ * The guarded linker the instance resolved at construction: the merged subjects
773
+ * to write, or `null` when nothing changed. Exposed as `t.linkSubjects` for the
774
+ * router factories, which reach it the way they reach the registry.
775
+ */
776
+ export type LinkSubjects = (
777
+ name: string,
778
+ spec: { subjects: readonly string[] },
779
+ tenantId: string,
780
+ declared: unknown,
781
+ ) => Promise<SubjectInput[] | null>;
782
+
783
+ /**
784
+ * Total subjects one record may carry once linking has run. `subjectKeys` is a
785
+ * multikey index term and every subject fans a `by:['subject']` rollup out one
786
+ * more time, so an unbounded array is unbounded write amplification with a
787
+ * host's cache bug behind it. Overflow is dropped and counted in
788
+ * `counters.subjectLinkCapped`.
789
+ */
790
+ export declare const SUBJECT_MAX: 8;
791
+
792
+ /** default `subjectLinkTimeoutMs` — past it the record is written unlinked */
793
+ export declare const SUBJECT_LINK_TIMEOUT_MS: 50;
794
+
715
795
  export interface Telemetry<R extends Registry = Registry> {
716
796
  /** write — the only write. The result says what actually happened to the row. */
717
797
  emit<N extends keyof R & string>(name: N, doc: EmitInput<R, N>): Promise<EmitResult>;
@@ -739,6 +819,14 @@ export interface Telemetry<R extends Registry = Registry> {
739
819
  counters: TelemetryCounters;
740
820
  /** the registry this instance validates against */
741
821
  registry: R;
822
+ /**
823
+ * Write-time subject linking, guarded and resolved once at construction;
824
+ * `null` without a `subjectLinker`. Exposed for the router factories: the
825
+ * wire path does not go through `emit()` — at-least-once delivery inverts the
826
+ * plane order — so `createIngest` reaches the one implementation here rather
827
+ * than growing a second copy of the rules.
828
+ */
829
+ linkSubjects: LinkSubjects | null;
742
830
  logger: Logger;
743
831
  /** mint an ingest key against this instance's key collection */
744
832
  createKey(input: CreateKeyInput): Promise<{ key: string; id: string }>;
package/types/test-d.ts CHANGED
@@ -34,8 +34,10 @@ import type {
34
34
  MetricsOf,
35
35
  Registry,
36
36
  RollupSpec,
37
+ LinkSubjects,
37
38
  Scoped,
38
39
  SubjectInput,
40
+ SubjectLinker,
39
41
  Telemetry,
40
42
  TelemetryCounters,
41
43
  } from './index.js';
@@ -61,6 +63,8 @@ import {
61
63
  RETENTION_DAYS,
62
64
  SAMPLE_RATE,
63
65
  SCHEMA_VERSION,
66
+ SUBJECT_LINK_TIMEOUT_MS,
67
+ SUBJECT_MAX,
64
68
  TelemetryKind,
65
69
  } from './index.js';
66
70
 
@@ -101,6 +105,15 @@ const registry = defineRegistry({
101
105
 
102
106
  declare const mongooseish: CreateTelemetryConfig['connection'];
103
107
 
108
+ /** WRITE-time: the desktop's machine ref, resolved to the account that owns it */
109
+ const subjectLinker: SubjectLinker = {
110
+ link: async (subjects, { name, tenantId }) => {
111
+ void name, tenantId;
112
+ const machine = subjects.find((s) => s.type === 'machine');
113
+ return machine ? [{ type: 'user', id: `u_${machine.id}`, role: 'owner' }] : [];
114
+ },
115
+ };
116
+
104
117
  const t = createTelemetry({
105
118
  registry,
106
119
  connection: mongooseish,
@@ -108,8 +121,15 @@ const t = createTelemetry({
108
121
  platforms: ['watchos'], // EXTENDS the builtins; 'web' still validates
109
122
  bodyMax: 4096,
110
123
  globalSubjectRefs: true, // refs name one party in every tenant — forget() reaches '*' views
124
+ subjectLinker,
125
+ subjectLinkTimeoutMs: SUBJECT_LINK_TIMEOUT_MS,
111
126
  });
112
127
 
128
+ // the guarded linker the instance resolved, shared with the router factories
129
+ const linkSubjects: LinkSubjects | null = t.linkSubjects;
130
+ const subjectCap: number = SUBJECT_MAX;
131
+ void linkSubjects, subjectCap;
132
+
113
133
  // ── emit is typed against the registry ──
114
134
  async function writes() {
115
135
  await t.emit('user.signed_up', {