@jeffjassky/telemetry 0.7.0 → 0.8.1

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.js CHANGED
@@ -2,6 +2,7 @@ import { uuidv7 } from 'uuidv7';
2
2
  import { z } from 'zod';
3
3
  import { Schema } from 'mongoose';
4
4
  import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'crypto';
5
+ import { originalPositionFor, sourceContentFor, TraceMap } from '@jridgewell/trace-mapping';
5
6
  import express2 from 'express';
6
7
  import fs from 'fs';
7
8
  import path2 from 'path';
@@ -1161,6 +1162,145 @@ function createSyncIndexes(ctx) {
1161
1162
  await ctx.rejects().createIndex({ at: 1 }, { expireAfterSeconds: REJECT_TTL_DAYS * 86400 });
1162
1163
  };
1163
1164
  }
1165
+ var SOURCEMAP_MAX_BYTES = 15 * 1024 * 1024;
1166
+ var SOURCEMAP_RETENTION_DAYS = 90;
1167
+ var CACHE_SIZE = 8;
1168
+ var CONTEXT_MAX = 200;
1169
+ var bundleFile = (filename) => String(filename ?? "").split(/[?#]/)[0].split(/[\\/]/).pop() ?? "";
1170
+ var cleanSource = (source) => (
1171
+ // webpack:///src/x.ts, webpack://app/./src/x.ts → src/x.ts
1172
+ source.replace(/^webpack:\/\/[^/]*\//, "").replace(/^\.\//, "")
1173
+ );
1174
+ function createSourcemaps(opts) {
1175
+ const coll = () => opts.connection.db.collection(opts.collection);
1176
+ const cache = /* @__PURE__ */ new Map();
1177
+ const remember = (key, value) => {
1178
+ cache.delete(key);
1179
+ cache.set(key, value);
1180
+ while (cache.size > CACHE_SIZE) cache.delete(cache.keys().next().value);
1181
+ };
1182
+ const keyOf = (tenantId, service, release, file) => `${tenantId}\0${service}\0${release}\0${file}`;
1183
+ async function load(tenantId, service, release, file) {
1184
+ const key = keyOf(tenantId, service, release, file);
1185
+ if (cache.has(key)) {
1186
+ const hit = cache.get(key);
1187
+ remember(key, hit);
1188
+ return hit;
1189
+ }
1190
+ let parsed = null;
1191
+ try {
1192
+ const doc = await coll().findOne({ tenantId, service, release, file }, { projection: { map: 1 } });
1193
+ if (doc?.map) parsed = new TraceMap(String(doc.map));
1194
+ } catch (e) {
1195
+ opts.logger.warn("[telemetry] sourcemap load failed", file, e?.message);
1196
+ }
1197
+ remember(key, parsed);
1198
+ return parsed;
1199
+ }
1200
+ return {
1201
+ async ensureIndexes() {
1202
+ await coll().createIndex(
1203
+ { tenantId: 1, service: 1, release: 1, file: 1 },
1204
+ { unique: true, name: "sourcemap_identity" }
1205
+ );
1206
+ await coll().createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0, name: "sourcemap_ttl" });
1207
+ },
1208
+ /**
1209
+ * Store maps for one release. Idempotent: registering the same release
1210
+ * again replaces its maps and refreshes their retention, so a host can
1211
+ * simply call this on every boot.
1212
+ */
1213
+ async register(input) {
1214
+ const { tenantId, service, release } = input;
1215
+ if (!tenantId || !service || !release) {
1216
+ throw new Error("telemetry: sourcemaps.register needs tenantId, service and release");
1217
+ }
1218
+ if (release === "unknown") {
1219
+ throw new Error('telemetry: refusing to register sourcemaps for release "unknown" \u2014 set a real release on the client');
1220
+ }
1221
+ const now = /* @__PURE__ */ new Date();
1222
+ const expiresAt = new Date(now.getTime() + SOURCEMAP_RETENTION_DAYS * 864e5);
1223
+ const skipped = [];
1224
+ let stored = 0;
1225
+ for (const f of input.files) {
1226
+ const file = bundleFile(f.file);
1227
+ const map = typeof f.map === "string" ? f.map : JSON.stringify(f.map);
1228
+ if (!file || !map) {
1229
+ skipped.push(f.file);
1230
+ continue;
1231
+ }
1232
+ if (Buffer.byteLength(map) > SOURCEMAP_MAX_BYTES) {
1233
+ opts.logger.warn(`[telemetry] sourcemap ${file} exceeds ${SOURCEMAP_MAX_BYTES} bytes \u2014 skipped`);
1234
+ skipped.push(f.file);
1235
+ continue;
1236
+ }
1237
+ try {
1238
+ JSON.parse(map);
1239
+ } catch {
1240
+ skipped.push(f.file);
1241
+ continue;
1242
+ }
1243
+ await coll().updateOne(
1244
+ { tenantId, service, release, file },
1245
+ { $set: { map, bytes: Buffer.byteLength(map), registeredAt: now, expiresAt } },
1246
+ { upsert: true }
1247
+ );
1248
+ cache.delete(keyOf(tenantId, service, release, file));
1249
+ stored++;
1250
+ }
1251
+ return { stored, skipped };
1252
+ },
1253
+ /** translate one position; null when no map is registered or it has no mapping there */
1254
+ async resolve(where, lineno, colno) {
1255
+ const file = bundleFile(where.filename);
1256
+ if (!file || !where.release || where.release === "unknown") return null;
1257
+ if (!Number.isFinite(lineno) || !Number.isFinite(colno)) return null;
1258
+ const map = await load(where.tenantId, where.service, where.release, file);
1259
+ if (!map) return null;
1260
+ const pos = originalPositionFor(map, { line: lineno, column: Math.max(0, colno - 1) });
1261
+ if (!pos.source || pos.line == null) return null;
1262
+ const out = {
1263
+ source: cleanSource(pos.source),
1264
+ line: pos.line,
1265
+ column: (pos.column ?? 0) + 1
1266
+ };
1267
+ if (pos.name) out.name = pos.name;
1268
+ const content = sourceContentFor(map, pos.source);
1269
+ const text = content?.split("\n")[pos.line - 1];
1270
+ if (text != null) out.context = text.trim().slice(0, CONTEXT_MAX);
1271
+ return out;
1272
+ },
1273
+ /**
1274
+ * A copy of an error record with `original` set on every frame a map
1275
+ * could translate. Anything else comes back unchanged — never throws.
1276
+ */
1277
+ async symbolicate(record) {
1278
+ const frames = record?.error?.frames;
1279
+ if (record?.kind !== "error" || !Array.isArray(frames) || !frames.length) return record;
1280
+ try {
1281
+ const translated = await Promise.all(
1282
+ frames.map(async (f) => {
1283
+ const original = await this.resolve(
1284
+ {
1285
+ tenantId: record.tenantId,
1286
+ service: record.service,
1287
+ release: record.release,
1288
+ filename: f?.filename
1289
+ },
1290
+ Number(f?.lineno),
1291
+ Number(f?.colno)
1292
+ );
1293
+ return original ? { ...f, original } : f;
1294
+ })
1295
+ );
1296
+ return { ...record, error: { ...record.error, frames: translated } };
1297
+ } catch (e) {
1298
+ opts.logger.warn("[telemetry] symbolicate failed", e?.message);
1299
+ return record;
1300
+ }
1301
+ }
1302
+ };
1303
+ }
1164
1304
  var KeyKind = { Publishable: "publishable", Secret: "secret" };
1165
1305
  var TenantMode = { Fixed: "fixed", Session: "session", Claimed: "claimed" };
1166
1306
  var KEY_RE = /^(pk|sk)_([a-z0-9]+)_(tk_[a-f0-9]{24})(?:_([a-f0-9]{48}))?$/;
@@ -2102,6 +2242,31 @@ var BREAKDOWN_FIELDS = [
2102
2242
  "error.type",
2103
2243
  "error.handled"
2104
2244
  ];
2245
+ var GENERIC_SUBJECT_TYPES = ["anon", "session"];
2246
+ var mostSpecificSubject = {
2247
+ $let: {
2248
+ vars: { keys: { $ifNull: ["$subjectKeys", []] } },
2249
+ in: {
2250
+ $ifNull: [
2251
+ {
2252
+ $arrayElemAt: [
2253
+ {
2254
+ $filter: {
2255
+ input: "$$keys",
2256
+ as: "k",
2257
+ cond: {
2258
+ $not: [{ $in: [{ $arrayElemAt: [{ $split: ["$$k", ":"] }, 0] }, GENERIC_SUBJECT_TYPES] }]
2259
+ }
2260
+ }
2261
+ },
2262
+ 0
2263
+ ]
2264
+ },
2265
+ { $arrayElemAt: ["$$keys", 0] }
2266
+ ]
2267
+ }
2268
+ }
2269
+ };
2105
2270
  var typePrefix = (ref) => ({
2106
2271
  $let: {
2107
2272
  vars: { ref },
@@ -2129,7 +2294,7 @@ function dimExpression(dim2) {
2129
2294
  }
2130
2295
  return { $ifNull: [`$${path3}`, null] };
2131
2296
  }
2132
- if (dim2 === "subjectType") return typePrefix({ $arrayElemAt: ["$subjectKeys", 0] });
2297
+ if (dim2 === "subjectType") return typePrefix(mostSpecificSubject);
2133
2298
  if (dim2 === "actorType") return typePrefix("$actor");
2134
2299
  throw badRequest(
2135
2300
  `"${dim2}" is not a dimension. Use "attr:<key>", "field:<path>", "subjectType" or "actorType".`
@@ -3620,12 +3785,14 @@ function createDashboard(opts) {
3620
3785
  scope: req.viewer.tenantId,
3621
3786
  platform: isPlatformScope(req.viewer.tenantId)
3622
3787
  })));
3623
- api.get("/records", h(
3624
- async (req) => q.records(req.viewer.tenantId, parseRange(req.query), parseFilter(req.query), {
3788
+ api.get("/records", h(async (req) => {
3789
+ const page = await q.records(req.viewer.tenantId, parseRange(req.query), parseFilter(req.query), {
3625
3790
  limit: req.query.limit ? Number(req.query.limit) : void 0,
3626
3791
  cursor: typeof req.query.cursor === "string" ? req.query.cursor : void 0
3627
- })
3628
- ));
3792
+ });
3793
+ if (!t.sourcemaps || !page?.items?.some((r) => r?.kind === "error")) return page;
3794
+ return { ...page, items: await Promise.all(page.items.map((r) => t.sourcemaps.symbolicate(r))) };
3795
+ }));
3629
3796
  api.get("/series", h(
3630
3797
  async (req) => q.series(req.viewer.tenantId, parseRange(req.query), parseFilter(req.query), {
3631
3798
  measure: typeof req.query.measure === "string" ? req.query.measure : void 0,
@@ -3922,12 +4089,18 @@ function createTelemetry(config) {
3922
4089
  logger,
3923
4090
  linkSubjects
3924
4091
  });
3925
- const syncIndexes = createSyncIndexes({
4092
+ const syncModelIndexes = createSyncIndexes({
3926
4093
  registry,
3927
4094
  TelemetryModel,
3928
4095
  models: [TelemetryModel, ...Object.values(byKind), RollupModel, CheckpointModel, KeyModel],
3929
4096
  rejects
3930
4097
  });
4098
+ const sourcemaps = createSourcemaps({ connection: conn, collection: `${collection}_sourcemaps`, logger });
4099
+ const syncIndexes = async (...args) => {
4100
+ const result = await syncModelIndexes(...args);
4101
+ await sourcemaps.ensureIndexes();
4102
+ return result;
4103
+ };
3931
4104
  return {
3932
4105
  /** write — the only write */
3933
4106
  emit,
@@ -3994,6 +4167,13 @@ function createTelemetry(config) {
3994
4167
  */
3995
4168
  linkSubjects,
3996
4169
  logger,
4170
+ /**
4171
+ * Sourcemaps for minified clients. `register()` stores a release's maps
4172
+ * (server-side only — there is no HTTP route); the dashboard translates
4173
+ * error frames against them at read time, so errors recorded before the
4174
+ * maps were registered are translated too. See sourcemaps.ts.
4175
+ */
4176
+ sourcemaps,
3997
4177
  /** mint an ingest key; the full key string is returned once, never again */
3998
4178
  createKey: (input) => createKey(KeyModel, input),
3999
4179
  /** the models, exposed for hosts and the router factories */