@jeffjassky/telemetry 0.5.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 CHANGED
@@ -560,10 +560,11 @@ var truncate = (d, b) => {
560
560
  if (b === "day") return day;
561
561
  return new Date(day.getTime() - (day.getUTCDay() + 6) % 7 * 864e5);
562
562
  };
563
- async function recordRollup(RollupModel, doc, name, spec, counters) {
563
+ async function recordRollup(RollupModel, doc, name, spec, counters, opts) {
564
+ const dry = opts?.dryRun === true;
564
565
  if (spec.actors && doc.actor) {
565
566
  const actorType = String(doc.actor).split(":")[0];
566
- if (!spec.actors.includes(actorType)) return;
567
+ if (!spec.actors.includes(actorType)) return 0;
567
568
  }
568
569
  const as = spec.as ?? name;
569
570
  const at = doc.occurredAt;
@@ -575,9 +576,11 @@ async function recordRollup(RollupModel, doc, name, spec, counters) {
575
576
  let v = resolveDim(src, doc);
576
577
  if (v == null || v === "") {
577
578
  if (spec.dimDefault === void 0) {
578
- counters.rollupSkipped++;
579
- bumpCounterMap(counters.rollupSkippedBy, `${as}|${label(src)}`);
580
- return;
579
+ if (!dry) {
580
+ counters.rollupSkipped++;
581
+ bumpCounterMap(counters.rollupSkippedBy, `${as}|${label(src)}`);
582
+ }
583
+ return 0;
581
584
  }
582
585
  v = spec.dimDefault;
583
586
  }
@@ -587,48 +590,48 @@ async function recordRollup(RollupModel, doc, name, spec, counters) {
587
590
  const refs = fansOut ? (doc.subjectKeys ?? []).filter(
588
591
  (r) => !spec.subjects || spec.subjects.includes(r.split(":")[0])
589
592
  ) : [null];
590
- if (!refs.length) return;
593
+ if (!refs.length) return 0;
591
594
  const firstCapture = Object.fromEntries(
592
595
  (spec.capture ?? []).map((src) => [label(src), resolveDim(src, doc)]).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)])
593
596
  );
594
597
  const expiresAt = spec.retentionDays != null ? new Date(at.getTime() + spec.retentionDays * 864e5) : void 0;
595
- await RollupModel.bulkWrite(
596
- refs.map((ref) => {
597
- const dims = spec.by.map((src) => src === "subject" ? ref : fixed.get(src));
598
- const isNewFirst = {
599
- $or: [{ $eq: [{ $type: "$firstAt" }, "missing"] }, { $lt: [at, "$firstAt"] }]
600
- };
601
- const sums = Object.fromEntries(
602
- (spec.sum ?? []).map((k) => [k, doc.metrics?.get(k)]).filter(([, v]) => typeof v === "number").map(([k, v]) => [`sums.${k}`, { $add: [{ $ifNull: [`$sums.${k}`, 0] }, v] }])
603
- );
604
- return {
605
- updateOne: {
606
- filter: { _id: `${doc.tenantId}|${as}|${dims.join("|")}|${bucketKey}` },
607
- update: [
608
- {
609
- $set: {
610
- tenantId: doc.tenantId,
611
- as,
612
- dims,
613
- ...ref ? { subjectType: ref.split(":")[0] } : {},
614
- ...bucketAt ? { bucketAt } : {},
615
- ...expiresAt ? { expiresAt } : {},
616
- // aggregation $min/$max ignore missing, so correct on insert too
617
- firstAt: { $min: ["$firstAt", at] },
618
- lastAt: { $max: ["$lastAt", at] },
619
- count: { $add: [{ $ifNull: ["$count", 0] }, 1] },
620
- ...sums,
621
- firstTraceId: { $cond: [isNewFirst, doc.traceId ?? null, "$firstTraceId"] },
622
- firstCapture: { $cond: [isNewFirst, { $literal: firstCapture }, "$firstCapture"] }
623
- }
598
+ const ops = refs.map((ref) => {
599
+ const dims = spec.by.map((src) => src === "subject" ? ref : fixed.get(src));
600
+ const isNewFirst = {
601
+ $or: [{ $eq: [{ $type: "$firstAt" }, "missing"] }, { $lt: [at, "$firstAt"] }]
602
+ };
603
+ const sums = Object.fromEntries(
604
+ (spec.sum ?? []).map((k) => [k, doc.metrics?.get(k)]).filter(([, v]) => typeof v === "number").map(([k, v]) => [`sums.${k}`, { $add: [{ $ifNull: [`$sums.${k}`, 0] }, v] }])
605
+ );
606
+ return {
607
+ updateOne: {
608
+ filter: { _id: `${doc.tenantId}|${as}|${dims.join("|")}|${bucketKey}` },
609
+ update: [
610
+ {
611
+ $set: {
612
+ tenantId: doc.tenantId,
613
+ as,
614
+ dims,
615
+ ...ref ? { subjectType: ref.split(":")[0] } : {},
616
+ ...bucketAt ? { bucketAt } : {},
617
+ ...expiresAt ? { expiresAt } : {},
618
+ // aggregation $min/$max ignore missing, so correct on insert too
619
+ firstAt: { $min: ["$firstAt", at] },
620
+ lastAt: { $max: ["$lastAt", at] },
621
+ count: { $add: [{ $ifNull: ["$count", 0] }, 1] },
622
+ ...sums,
623
+ firstTraceId: { $cond: [isNewFirst, doc.traceId ?? null, "$firstTraceId"] },
624
+ firstCapture: { $cond: [isNewFirst, { $literal: firstCapture }, "$firstCapture"] }
624
625
  }
625
- ],
626
- upsert: true
627
- }
628
- };
629
- }),
630
- { ordered: false }
631
- );
626
+ }
627
+ ],
628
+ upsert: true
629
+ }
630
+ };
631
+ });
632
+ if (dry) return ops.length;
633
+ await RollupModel.bulkWrite(ops, { ordered: false });
634
+ return ops.length;
632
635
  }
633
636
  function buildCheckpointModel(connection, modelName, collection) {
634
637
  const existing = connection.models?.[modelName];
@@ -992,6 +995,109 @@ function createForget(ctx) {
992
995
  };
993
996
  }
994
997
 
998
+ // src/server/relink.ts
999
+ var RELINK_BATCH_SIZE = 500;
1000
+ var refKey = (s) => `${s.type}:${s.id}`;
1001
+ function createRelink(ctx) {
1002
+ const { registry, TelemetryModel, RollupModel, counters, logger, linkSubjects } = ctx;
1003
+ return async function relink(opts = {}) {
1004
+ const dryRun = opts.dryRun !== false;
1005
+ const batchSize = opts.batchSize ?? RELINK_BATCH_SIZE;
1006
+ const result = {
1007
+ examined: 0,
1008
+ linked: 0,
1009
+ subjects: 0,
1010
+ rollups: 0,
1011
+ misses: 0,
1012
+ errors: 0,
1013
+ skipped: 0
1014
+ };
1015
+ if (opts.names) {
1016
+ const unknown = opts.names.filter((n) => !registry[n]);
1017
+ if (unknown.length) {
1018
+ throw new Error(
1019
+ `telemetry: relink() was given names this registry does not declare: ${unknown.join(", ")}. A name with no spec has no rollup families to replay, so relinking it could only desynchronize its rows from its aggregates. Omit \`names\` to sweep everything.`
1020
+ );
1021
+ }
1022
+ }
1023
+ if (!linkSubjects) {
1024
+ logger.warn(
1025
+ "[telemetry] relink() has no subjectLinker to ask \u2014 nothing was read and nothing was written. Configure createTelemetry({ subjectLinker }) first; relink() backfills what that hook would have done, it does not replace it."
1026
+ );
1027
+ result.skipped = 1;
1028
+ return result;
1029
+ }
1030
+ if (opts.limit != null && opts.limit <= 0) return result;
1031
+ const filter = {};
1032
+ if (opts.names) filter.name = { $in: opts.names };
1033
+ if (opts.since) filter.occurredAt = { $gte: opts.since };
1034
+ const query = TelemetryModel.find(filter).hint({ _id: 1 }).batchSize(batchSize);
1035
+ if (opts.limit != null) query.limit(opts.limit);
1036
+ const progress = () => {
1037
+ if (!opts.onProgress) return;
1038
+ try {
1039
+ opts.onProgress({ ...result });
1040
+ } catch (e) {
1041
+ logger.warn(`[telemetry] relink() onProgress threw \u2014 ignored, the backfill continues: ${e}`);
1042
+ }
1043
+ };
1044
+ const cursor = query.cursor();
1045
+ let sinceProgress = 0;
1046
+ try {
1047
+ for await (const doc of cursor) {
1048
+ result.examined++;
1049
+ sinceProgress++;
1050
+ if (sinceProgress >= batchSize) {
1051
+ sinceProgress = 0;
1052
+ progress();
1053
+ }
1054
+ const spec = registry[doc.name];
1055
+ if (!spec) {
1056
+ result.skipped++;
1057
+ continue;
1058
+ }
1059
+ const declared = (doc.subjects ?? []).map(
1060
+ (s) => s.role ? { type: s.type, id: s.id, role: s.role } : { type: s.type, id: s.id }
1061
+ );
1062
+ const m0 = counters.subjectLinkMisses;
1063
+ const e0 = counters.subjectLinkErrors + counters.subjectLinkTimeouts;
1064
+ const merged = await linkSubjects(doc.name, spec, doc.tenantId, declared);
1065
+ result.misses += counters.subjectLinkMisses - m0;
1066
+ if (counters.subjectLinkErrors + counters.subjectLinkTimeouts > e0) result.errors++;
1067
+ if (!merged) continue;
1068
+ const had = new Set(declared.map(refKey));
1069
+ const mergedKeys = [];
1070
+ const newKeys = [];
1071
+ for (const s of merged) {
1072
+ const key = refKey(s);
1073
+ if (mergedKeys.includes(key)) continue;
1074
+ mergedKeys.push(key);
1075
+ if (!had.has(key)) newKeys.push(key);
1076
+ }
1077
+ if (!newKeys.length) continue;
1078
+ result.linked++;
1079
+ result.subjects += newKeys.length;
1080
+ if (!dryRun) {
1081
+ await TelemetryModel.updateOne(
1082
+ { _id: doc._id },
1083
+ { $set: { subjects: merged, subjectKeys: mergedKeys } }
1084
+ );
1085
+ }
1086
+ doc.subjectKeys = newKeys;
1087
+ for (const r of spec.rollups ?? []) {
1088
+ if (!r.by.includes("subject")) continue;
1089
+ result.rollups += await recordRollup(RollupModel, doc, doc.name, r, counters, { dryRun });
1090
+ }
1091
+ }
1092
+ } finally {
1093
+ await cursor.close().catch(() => {
1094
+ });
1095
+ }
1096
+ if (sinceProgress) progress();
1097
+ return result;
1098
+ };
1099
+ }
1100
+
995
1101
  // src/server/indexes.ts
996
1102
  var INDEX_BUDGET = 24;
997
1103
  function createSyncIndexes(ctx) {
@@ -3783,6 +3889,14 @@ function createTelemetry(config) {
3783
3889
  },
3784
3890
  globalSubjectRefs: () => config.globalSubjectRefs === true
3785
3891
  });
3892
+ const relink = createRelink({
3893
+ registry,
3894
+ TelemetryModel,
3895
+ RollupModel,
3896
+ counters,
3897
+ logger,
3898
+ linkSubjects
3899
+ });
3786
3900
  const syncIndexes = createSyncIndexes({
3787
3901
  registry,
3788
3902
  TelemetryModel,
@@ -3794,6 +3908,21 @@ function createTelemetry(config) {
3794
3908
  emit,
3795
3909
  /** erasure: delete sole-party rows, redact shared ones, rekey rollups, drop aliases */
3796
3910
  forget,
3911
+ /**
3912
+ * Backfill: re-ask the `subjectLinker` about records ALREADY on disk, and
3913
+ * replay the rollups the new subjects reach.
3914
+ *
3915
+ * Linking happens at write time, so configuring it fixes the future and
3916
+ * nothing else — a lifetime `by:['subject']` family is keyed on the subject
3917
+ * the record was written with, and a read-time join cannot reach back into
3918
+ * it. This is how a host catches up the backlog it adopted the hook with.
3919
+ *
3920
+ * DRY RUN BY DEFAULT: it rewrites historical aggregates, so the short call
3921
+ * reports and the writing call says `{ dryRun: false }`. Idempotent by
3922
+ * construction — a row that already carries the linked subject yields
3923
+ * nothing new, so a second run is a no-op.
3924
+ */
3925
+ relink,
3797
3926
  /**
3798
3927
  * Tenant scope is not optional — force every read through here. The five
3799
3928
  * dashboard query primitives (records/series/distribution/rollups/journey)
@@ -3866,6 +3995,7 @@ exports.LogLevel = LogLevel;
3866
3995
  exports.MAX_SUGGESTIONS = MAX_SUGGESTIONS;
3867
3996
  exports.Origin = Origin;
3868
3997
  exports.PLATFORM_SCOPE = PLATFORM_SCOPE;
3998
+ exports.RELINK_BATCH_SIZE = RELINK_BATCH_SIZE;
3869
3999
  exports.RETENTION_DAYS = RETENTION_DAYS;
3870
4000
  exports.SAMPLE_RATE = SAMPLE_RATE;
3871
4001
  exports.SCHEMA_VERSION = SCHEMA_VERSION;