@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.
package/dist/index.cjs CHANGED
@@ -70,7 +70,13 @@ var newCounters = () => ({
70
70
  deduped: 0,
71
71
  truncated: 0,
72
72
  rollupSkippedBy: {},
73
- undeclaredAttrs: {}
73
+ undeclaredAttrs: {},
74
+ subjectsLinked: 0,
75
+ subjectLinkMisses: 0,
76
+ subjectLinkErrors: 0,
77
+ subjectLinkTimeouts: 0,
78
+ subjectLinkUndeclared: 0,
79
+ subjectLinkCapped: 0
74
80
  });
75
81
  var COUNTER_MAP_MAX = 1e3;
76
82
  var COUNTER_OVERFLOW_KEY = "(other)|(other)";
@@ -672,6 +678,110 @@ function noteUndeclaredAttrs(counters, name, spec, attrs) {
672
678
  bumpCounterMap(counters.undeclaredAttrs, `${name}|${key}`);
673
679
  }
674
680
  }
681
+ var SUBJECT_MAX = 8;
682
+ var SUBJECT_LINK_TIMEOUT_MS = 50;
683
+ var LINK_TIMEOUT = /* @__PURE__ */ Symbol("telemetry.subjectLink.timeout");
684
+ function createSubjectLinking(opts) {
685
+ const { linker, counters, logger } = opts;
686
+ if (!linker) return null;
687
+ const timeoutMs = opts.timeoutMs ?? SUBJECT_LINK_TIMEOUT_MS;
688
+ const warned = /* @__PURE__ */ new Set();
689
+ const warnOnce = (key, msg) => {
690
+ if (warned.has(key) || warned.size >= COUNTER_MAP_MAX) return;
691
+ warned.add(key);
692
+ logger.warn(msg);
693
+ };
694
+ return async function linkSubjects(name, spec, tenantId, declared) {
695
+ const have = Array.isArray(declared) ? declared : [];
696
+ const seen = /* @__PURE__ */ new Set();
697
+ const view = [];
698
+ for (const s of have) {
699
+ const ref = wellFormed(s);
700
+ if (!ref) continue;
701
+ seen.add(`${ref.type}:${ref.id}`);
702
+ view.push(ref);
703
+ }
704
+ let out;
705
+ let timer;
706
+ try {
707
+ out = await Promise.race([
708
+ // the async wrapper turns a SYNCHRONOUS throw into a rejection, so a
709
+ // linker that dies on its first line lands in the same catch as one
710
+ // whose promise rejects
711
+ (async () => linker.link(view, { name, tenantId }))(),
712
+ new Promise((_, reject) => {
713
+ timer = setTimeout(() => reject(LINK_TIMEOUT), timeoutMs);
714
+ })
715
+ ]);
716
+ } catch (e) {
717
+ if (e === LINK_TIMEOUT) {
718
+ counters.subjectLinkTimeouts++;
719
+ warnOnce(
720
+ "timeout",
721
+ `[telemetry] subjectLinker.link() exceeded ${timeoutMs}ms \u2014 records are being written UNLINKED rather than waiting. The hook is expected to answer from a cache; a resolver that queries per record cannot keep up with ingest. Warned once \u2014 the count is counters.subjectLinkTimeouts.`
722
+ );
723
+ } else {
724
+ counters.subjectLinkErrors++;
725
+ warnOnce(
726
+ "threw",
727
+ `[telemetry] subjectLinker.link() threw \u2014 records are being written unlinked: ${e}. Warned once \u2014 the count is counters.subjectLinkErrors.`
728
+ );
729
+ }
730
+ return null;
731
+ } finally {
732
+ clearTimeout(timer);
733
+ }
734
+ if (!Array.isArray(out)) {
735
+ counters.subjectLinkErrors++;
736
+ warnOnce(
737
+ "shape",
738
+ `[telemetry] subjectLinker.link() resolved to ${typeof out}, not an array \u2014 records are being written unlinked. Return [] when nothing links. Warned once \u2014 the count is counters.subjectLinkErrors.`
739
+ );
740
+ return null;
741
+ }
742
+ if (!out.length) {
743
+ counters.subjectLinkMisses++;
744
+ return null;
745
+ }
746
+ let room = Math.max(0, SUBJECT_MAX - have.length);
747
+ let capped2 = 0;
748
+ const add = [];
749
+ for (const s of out) {
750
+ const ref = wellFormed(s);
751
+ if (!ref) {
752
+ counters.subjectLinkErrors++;
753
+ warnOnce(
754
+ "entry",
755
+ "[telemetry] subjectLinker returned an entry that is not { type, id } \u2014 dropped. Warned once \u2014 the count is counters.subjectLinkErrors."
756
+ );
757
+ continue;
758
+ }
759
+ const key = `${ref.type}:${ref.id}`;
760
+ if (seen.has(key)) continue;
761
+ if (!spec.subjects.includes(ref.type)) {
762
+ counters.subjectLinkUndeclared++;
763
+ }
764
+ if (room <= 0) {
765
+ capped2++;
766
+ continue;
767
+ }
768
+ seen.add(key);
769
+ room--;
770
+ add.push(ref);
771
+ }
772
+ counters.subjectLinkCapped += capped2;
773
+ if (!add.length) return null;
774
+ counters.subjectsLinked += add.length;
775
+ return [...have, ...add];
776
+ };
777
+ }
778
+ function wellFormed(s) {
779
+ if (!s || typeof s !== "object") return null;
780
+ const { type, id, role } = s;
781
+ if (typeof type !== "string" || !type) return null;
782
+ if (typeof id !== "string" || !id) return null;
783
+ return typeof role === "string" && role ? { type, id, role } : { type, id };
784
+ }
675
785
  function createEmitter(ctx) {
676
786
  const { registry, byKind, RollupModel, rejects, counters } = ctx;
677
787
  const burstBuckets = /* @__PURE__ */ new Map();
@@ -708,11 +818,15 @@ function createEmitter(ctx) {
708
818
  const durable = kind === TelemetryKind.Usage || (doc.durable ?? spec.durable ?? false);
709
819
  const Model = byKind[kind];
710
820
  const { forceKeep: _drop, durable: _durable, ...rest } = doc;
821
+ const linked = ctx.linkSubjects ? await ctx.linkSubjects(name, spec, doc.tenantId, doc.subjects) : null;
711
822
  const safe = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [k.replace(/\./g, "_"), v]));
712
823
  const payload = {
713
824
  ...rest,
714
825
  _id: id,
715
826
  name,
827
+ // computed like everything below it, and absent when nothing linked, so a
828
+ // host with no linker hands the model the exact object 0.4.0 did
829
+ ...linked ? { subjects: linked } : {},
716
830
  sampleRate: forced ? 1 : baseRate,
717
831
  forced,
718
832
  attrs: safe(doc.attrs),
@@ -1221,6 +1335,8 @@ function createIngest(opts) {
1221
1335
  const occurredAt = Number.isFinite(occurredRaw) ? new Date(occurredRaw - clockSkewMs) : receivedAt;
1222
1336
  const safeMap = (o) => o && typeof o === "object" ? new Map(Object.entries(o).map(([k, v]) => [k.replace(/\./g, "_"), v])) : /* @__PURE__ */ new Map();
1223
1337
  noteUndeclaredAttrs(t.counters, name, spec, rec.attrs);
1338
+ const subjects = mergeSubjects(rec.subjects);
1339
+ const linked = t.linkSubjects ? await t.linkSubjects(name, spec, tenantId, subjects) : null;
1224
1340
  const Model = t.models.byKind[spec.kind];
1225
1341
  const d = new Model({
1226
1342
  // facts the wire may not assert: tenant, service, env, origin, plane
@@ -1231,7 +1347,7 @@ function createIngest(opts) {
1231
1347
  tenantId,
1232
1348
  occurredAt,
1233
1349
  severity: typeof rec.severity === "string" ? rec.severity : void 0,
1234
- subjects: mergeSubjects(rec.subjects),
1350
+ subjects: linked ?? subjects,
1235
1351
  actor: ctx.actor ?? (typeof rec.actor === "string" ? rec.actor : batchActor),
1236
1352
  onBehalfOf: typeof rec.onBehalfOf === "string" ? rec.onBehalfOf : void 0,
1237
1353
  service: key.service,
@@ -3634,7 +3750,22 @@ function createTelemetry(config) {
3634
3750
  inFlight.add(p);
3635
3751
  void p.finally(() => inFlight.delete(p));
3636
3752
  };
3637
- const emit = createEmitter({ registry, byKind, RollupModel, rejects, counters, logger, track });
3753
+ const linkSubjects = createSubjectLinking({
3754
+ linker: config.subjectLinker,
3755
+ timeoutMs: config.subjectLinkTimeoutMs,
3756
+ counters,
3757
+ logger
3758
+ });
3759
+ const emit = createEmitter({
3760
+ registry,
3761
+ byKind,
3762
+ RollupModel,
3763
+ rejects,
3764
+ counters,
3765
+ logger,
3766
+ track,
3767
+ linkSubjects
3768
+ });
3638
3769
  const forget = createForget({
3639
3770
  TelemetryModel,
3640
3771
  RollupModel,
@@ -3697,6 +3828,17 @@ function createTelemetry(config) {
3697
3828
  counters,
3698
3829
  /** the registry, exposed for the router factories — hosts should import their own */
3699
3830
  registry,
3831
+ /**
3832
+ * Write-time subject linking, exposed for the router factories. `null` when
3833
+ * no `subjectLinker` is configured.
3834
+ *
3835
+ * The wire path does not go through emit() — createIngest() builds its
3836
+ * record itself, because at-least-once delivery inverts the plane order
3837
+ * (insert first, THEN aggregate). So it reaches the linker the same way it
3838
+ * reaches the registry and the models: off the instance, running the one
3839
+ * implementation, rather than growing a second copy of the rules.
3840
+ */
3841
+ linkSubjects,
3700
3842
  logger,
3701
3843
  /** mint an ingest key; the full key string is returned once, never again */
3702
3844
  createKey: (input) => createKey(KeyModel, input),
@@ -3727,6 +3869,8 @@ exports.PLATFORM_SCOPE = PLATFORM_SCOPE;
3727
3869
  exports.RETENTION_DAYS = RETENTION_DAYS;
3728
3870
  exports.SAMPLE_RATE = SAMPLE_RATE;
3729
3871
  exports.SCHEMA_VERSION = SCHEMA_VERSION;
3872
+ exports.SUBJECT_LINK_TIMEOUT_MS = SUBJECT_LINK_TIMEOUT_MS;
3873
+ exports.SUBJECT_MAX = SUBJECT_MAX;
3730
3874
  exports.TelemetryKind = TelemetryKind;
3731
3875
  exports.TenantMode = TenantMode;
3732
3876
  exports.boundedMeta = boundedMeta;