@neat.is/core 0.3.5 → 0.3.7

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.d.cts CHANGED
@@ -358,6 +358,29 @@ interface DaemonHandle {
358
358
  *
359
359
  * Pure function. Daemon callers pass a snapshot of the registry to avoid
360
360
  * per-span fs reads.
361
+ *
362
+ * Matching order (ADR-072 — real-world `service.name` rarely equals project
363
+ * name; monorepos publish per-package names like `brief-api` under a
364
+ * project named `brief`):
365
+ *
366
+ * 1. Exact: `entry.name === serviceName`.
367
+ * 2. Hyphen/underscore-separated prefix: `entry.name` is a leading token
368
+ * of `serviceName` (`brief` matches `brief-api`, `brief_worker`).
369
+ * Longest-match wins so `brief-api` beats `brief` when both are
370
+ * registered.
371
+ * 3. Containment as a separator-delimited token (`api` inside
372
+ * `brief-api-staging`).
373
+ *
374
+ * Routing eligibility (ADR-071):
375
+ * - `active` matches at every pass (the steady-state path).
376
+ * - `broken` also matches — the daemon needs the span to reach the broken
377
+ * slot so the ingest-time auto-recover path can attempt a bootstrap and
378
+ * lift the project back to `active`. The router only chooses the target;
379
+ * whether the span actually lands is the ingest handler's decision.
380
+ * - `paused` is intentionally not routed; the operator paused it on
381
+ * purpose, so the span falls through to the default-project flow.
382
+ *
383
+ * Falls back to `DEFAULT_PROJECT` when nothing matches.
361
384
  */
362
385
  declare function routeSpanToProject(serviceName: string | undefined, projects: ReadonlyArray<RegistryEntry>): string;
363
386
  declare function startDaemon(opts?: DaemonOptions): Promise<DaemonHandle>;
package/dist/index.d.ts CHANGED
@@ -358,6 +358,29 @@ interface DaemonHandle {
358
358
  *
359
359
  * Pure function. Daemon callers pass a snapshot of the registry to avoid
360
360
  * per-span fs reads.
361
+ *
362
+ * Matching order (ADR-072 — real-world `service.name` rarely equals project
363
+ * name; monorepos publish per-package names like `brief-api` under a
364
+ * project named `brief`):
365
+ *
366
+ * 1. Exact: `entry.name === serviceName`.
367
+ * 2. Hyphen/underscore-separated prefix: `entry.name` is a leading token
368
+ * of `serviceName` (`brief` matches `brief-api`, `brief_worker`).
369
+ * Longest-match wins so `brief-api` beats `brief` when both are
370
+ * registered.
371
+ * 3. Containment as a separator-delimited token (`api` inside
372
+ * `brief-api-staging`).
373
+ *
374
+ * Routing eligibility (ADR-071):
375
+ * - `active` matches at every pass (the steady-state path).
376
+ * - `broken` also matches — the daemon needs the span to reach the broken
377
+ * slot so the ingest-time auto-recover path can attempt a bootstrap and
378
+ * lift the project back to `active`. The router only chooses the target;
379
+ * whether the span actually lands is the ingest handler's decision.
380
+ * - `paused` is intentionally not routed; the operator paused it on
381
+ * purpose, so the span falls through to the default-project flow.
382
+ *
383
+ * Falls back to `DEFAULT_PROJECT` when nothing matches.
361
384
  */
362
385
  declare function routeSpanToProject(serviceName: string | undefined, projects: ReadonlyArray<RegistryEntry>): string;
363
386
  declare function startDaemon(opts?: DaemonOptions): Promise<DaemonHandle>;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  routeSpanToProject,
3
3
  startDaemon
4
- } from "./chunk-KCEZSFU2.js";
4
+ } from "./chunk-EDHOCFOG.js";
5
5
  import {
6
6
  ProjectNameCollisionError,
7
7
  addProject,
@@ -37,15 +37,15 @@ import {
37
37
  thresholdForEdgeType,
38
38
  touchLastSeen,
39
39
  writeAtomically
40
- } from "./chunk-BUB3ASD5.js";
40
+ } from "./chunk-ZU2RQRCN.js";
41
41
  import {
42
42
  startOtelGrpcReceiver
43
- } from "./chunk-G3PDTGOW.js";
43
+ } from "./chunk-CY67YKNO.js";
44
44
  import {
45
45
  buildOtelReceiver,
46
46
  logSpanHandler,
47
47
  parseOtlpRequest
48
- } from "./chunk-4ASCXBZF.js";
48
+ } from "./chunk-75IBA4UL.js";
49
49
  export {
50
50
  ProjectNameCollisionError,
51
51
  addProject,
package/dist/neatd.cjs CHANGED
@@ -257,8 +257,7 @@ function parseOtlpRequest(body) {
257
257
  }
258
258
  return out;
259
259
  }
260
- function loadProtobufDecoder() {
261
- if (exportTraceServiceRequestType) return exportTraceServiceRequestType;
260
+ function loadProtoRoot() {
262
261
  const here = import_node_path35.default.dirname((0, import_node_url2.fileURLToPath)(importMetaUrl));
263
262
  const protoRoot = import_node_path35.default.resolve(here, "..", "proto");
264
263
  const root = new import_protobufjs.default.Root();
@@ -267,11 +266,32 @@ function loadProtobufDecoder() {
267
266
  "opentelemetry/proto/collector/trace/v1/trace_service.proto",
268
267
  { keepCase: true }
269
268
  );
269
+ return root;
270
+ }
271
+ function loadProtobufDecoder() {
272
+ if (exportTraceServiceRequestType) return exportTraceServiceRequestType;
273
+ const root = loadProtoRoot();
270
274
  exportTraceServiceRequestType = root.lookupType(
271
275
  "opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest"
272
276
  );
273
277
  return exportTraceServiceRequestType;
274
278
  }
279
+ function loadProtobufResponseEncoder() {
280
+ if (exportTraceServiceResponseType) return exportTraceServiceResponseType;
281
+ const root = loadProtoRoot();
282
+ exportTraceServiceResponseType = root.lookupType(
283
+ "opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse"
284
+ );
285
+ return exportTraceServiceResponseType;
286
+ }
287
+ function encodeProtobufResponseBody() {
288
+ if (cachedProtobufResponseBody) return cachedProtobufResponseBody;
289
+ const Type = loadProtobufResponseEncoder();
290
+ const msg = Type.create({});
291
+ const encoded = Type.encode(msg).finish();
292
+ cachedProtobufResponseBody = Buffer.from(encoded);
293
+ return cachedProtobufResponseBody;
294
+ }
275
295
  async function decodeProtobufBody(buf) {
276
296
  const Type = loadProtobufDecoder();
277
297
  const decoded = Type.decode(buf).toJSON();
@@ -318,7 +338,9 @@ async function buildOtelReceiver(opts) {
318
338
  app.post("/v1/traces", async (req2, reply) => {
319
339
  const ct = (req2.headers["content-type"] ?? "").toString().split(";")[0].trim().toLowerCase();
320
340
  let body;
341
+ let responseFlavor;
321
342
  if (ct === "application/x-protobuf") {
343
+ responseFlavor = "protobuf";
322
344
  try {
323
345
  body = await decodeProtobufBody(req2.body);
324
346
  } catch (err) {
@@ -327,6 +349,7 @@ async function buildOtelReceiver(opts) {
327
349
  });
328
350
  }
329
351
  } else if (!ct || ct === "application/json") {
352
+ responseFlavor = "json";
330
353
  body = req2.body ?? {};
331
354
  } else {
332
355
  return reply.code(415).send({ error: `unsupported content-type: ${ct}` });
@@ -344,7 +367,11 @@ async function buildOtelReceiver(opts) {
344
367
  }
345
368
  }
346
369
  enqueue(spans);
347
- return reply.code(200).send({ partialSuccess: {} });
370
+ if (responseFlavor === "protobuf") {
371
+ const buf = encodeProtobufResponseBody();
372
+ return reply.code(200).header("content-type", "application/x-protobuf").send(buf);
373
+ }
374
+ return reply.code(200).header("content-type", "application/json").send({ partialSuccess: {} });
348
375
  });
349
376
  const decorated = app;
350
377
  decorated.flushPending = async () => {
@@ -354,7 +381,7 @@ async function buildOtelReceiver(opts) {
354
381
  };
355
382
  return decorated;
356
383
  }
357
- var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType;
384
+ var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
358
385
  var init_otel = __esm({
359
386
  "src/otel.ts"() {
360
387
  "use strict";
@@ -364,6 +391,8 @@ var init_otel = __esm({
364
391
  import_fastify2 = __toESM(require("fastify"), 1);
365
392
  import_protobufjs = __toESM(require("protobufjs"), 1);
366
393
  exportTraceServiceRequestType = null;
394
+ exportTraceServiceResponseType = null;
395
+ cachedProtobufResponseBody = null;
367
396
  }
368
397
  });
369
398
 
@@ -371,6 +400,7 @@ var init_otel = __esm({
371
400
  init_cjs_shims();
372
401
  var import_node_fs22 = require("fs");
373
402
  var import_node_path38 = __toESM(require("path"), 1);
403
+ var import_node_module = require("module");
374
404
 
375
405
  // src/daemon.ts
376
406
  init_cjs_shims();
@@ -1585,7 +1615,7 @@ function buildErrorEventForReceiver(span) {
1585
1615
  service: span.service,
1586
1616
  traceId: span.traceId,
1587
1617
  spanId: span.spanId,
1588
- errorMessage: span.exception?.message ?? span.name ?? "unknown error",
1618
+ errorMessage: span.exception?.message ?? "unknown error",
1589
1619
  ...span.exception?.type ? { exceptionType: span.exception.type } : {},
1590
1620
  ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
1591
1621
  ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
@@ -1677,7 +1707,7 @@ async function handleSpan(ctx, span) {
1677
1707
  service: span.service,
1678
1708
  traceId: span.traceId,
1679
1709
  spanId: span.spanId,
1680
- errorMessage: span.exception?.message ?? span.name ?? "unknown error",
1710
+ errorMessage: span.exception?.message ?? "unknown error",
1681
1711
  ...span.exception?.type ? { exceptionType: span.exception.type } : {},
1682
1712
  ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
1683
1713
  ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
@@ -5054,13 +5084,35 @@ function neatHomeFor(opts) {
5054
5084
  function routeSpanToProject(serviceName, projects) {
5055
5085
  if (!serviceName) return DEFAULT_PROJECT;
5056
5086
  for (const entry2 of projects) {
5057
- if (entry2.status !== "active") continue;
5058
- if (entry2.languages.length === 0) {
5059
- }
5087
+ if (entry2.status === "paused") continue;
5060
5088
  if (entry2.name === serviceName) return entry2.name;
5061
5089
  }
5090
+ const candidates = [];
5091
+ for (const entry2 of projects) {
5092
+ if (entry2.status === "paused") continue;
5093
+ if (isTokenPrefix(entry2.name, serviceName)) candidates.push(entry2);
5094
+ }
5095
+ if (candidates.length > 0) {
5096
+ candidates.sort((a, b) => b.name.length - a.name.length);
5097
+ return candidates[0].name;
5098
+ }
5099
+ for (const entry2 of projects) {
5100
+ if (entry2.status === "paused") continue;
5101
+ if (isTokenContained(entry2.name, serviceName)) return entry2.name;
5102
+ }
5062
5103
  return DEFAULT_PROJECT;
5063
5104
  }
5105
+ function isTokenPrefix(prefix, full) {
5106
+ if (prefix.length >= full.length) return false;
5107
+ if (!full.startsWith(prefix)) return false;
5108
+ const sep = full.charAt(prefix.length);
5109
+ return sep === "-" || sep === "_";
5110
+ }
5111
+ function isTokenContained(needle, haystack) {
5112
+ if (!haystack.includes(needle)) return false;
5113
+ const tokens = haystack.split(/[-_]/);
5114
+ return tokens.includes(needle);
5115
+ }
5064
5116
  async function bootstrapProject(entry2) {
5065
5117
  const paths = pathsForProject(entry2.name, import_node_path36.default.join(entry2.path, "neat-out"));
5066
5118
  try {
@@ -5140,6 +5192,17 @@ async function startDaemon(opts = {}) {
5140
5192
  `);
5141
5193
  const slots = /* @__PURE__ */ new Map();
5142
5194
  const registry = new Projects();
5195
+ const DROP_WARN_INTERVAL_MS = 6e4;
5196
+ const lastDropWarnAt = /* @__PURE__ */ new Map();
5197
+ function warnDroppedSpan(project, reason) {
5198
+ const now = Date.now();
5199
+ const prev = lastDropWarnAt.get(project) ?? 0;
5200
+ if (now - prev < DROP_WARN_INTERVAL_MS) return;
5201
+ lastDropWarnAt.set(project, now);
5202
+ console.warn(
5203
+ `[neatd] dropping span for project "${project}" \u2014 project status: broken (${reason}). Run \`neatd reload\` to retry bootstrap.`
5204
+ );
5205
+ }
5143
5206
  function upsertRegistryFromSlot(slot) {
5144
5207
  if (slot.status !== "active") return;
5145
5208
  registry.set(slot.entry.name, {
@@ -5148,12 +5211,38 @@ async function startDaemon(opts = {}) {
5148
5211
  graph: slot.graph
5149
5212
  });
5150
5213
  }
5214
+ async function tryRecoverSlot(entry2) {
5215
+ try {
5216
+ const fresh = await bootstrapProject(entry2);
5217
+ slots.set(entry2.name, fresh);
5218
+ upsertRegistryFromSlot(fresh);
5219
+ if (fresh.status === "active") {
5220
+ await setStatus(entry2.name, "active").catch(() => {
5221
+ });
5222
+ console.log(
5223
+ `neatd: project "${entry2.name}" recovered from broken \u2014 active`
5224
+ );
5225
+ }
5226
+ return fresh;
5227
+ } catch (err) {
5228
+ console.warn(
5229
+ `neatd: project "${entry2.name}" still broken after recovery attempt \u2014 ${err.message}`
5230
+ );
5231
+ return slots.get(entry2.name);
5232
+ }
5233
+ }
5151
5234
  async function loadAll() {
5152
5235
  const projects = await listProjects();
5153
5236
  const seen = /* @__PURE__ */ new Set();
5154
5237
  for (const entry2 of projects) {
5155
5238
  seen.add(entry2.name);
5156
- if (slots.has(entry2.name)) continue;
5239
+ const existing = slots.get(entry2.name);
5240
+ if (existing) {
5241
+ if (existing.status === "broken") {
5242
+ await tryRecoverSlot(entry2);
5243
+ }
5244
+ continue;
5245
+ }
5157
5246
  try {
5158
5247
  const slot = await bootstrapProject(entry2);
5159
5248
  slots.set(entry2.name, slot);
@@ -5210,13 +5299,28 @@ async function startDaemon(opts = {}) {
5210
5299
  `neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
5211
5300
  );
5212
5301
  }
5302
+ async function resolveTargetSlot(serviceName) {
5303
+ const liveEntries = await listProjects().catch(() => []);
5304
+ const target = routeSpanToProject(serviceName, liveEntries);
5305
+ let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5306
+ if (!slot) return null;
5307
+ if (slot.status === "broken") {
5308
+ const entry2 = liveEntries.find((e) => e.name === slot.entry.name);
5309
+ if (entry2) {
5310
+ slot = await tryRecoverSlot(entry2);
5311
+ }
5312
+ if (slot.status !== "active") {
5313
+ warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
5314
+ return null;
5315
+ }
5316
+ }
5317
+ return slot.status === "active" ? slot : null;
5318
+ }
5213
5319
  try {
5214
5320
  otlpApp = await buildOtelReceiver({
5215
5321
  onSpan: async (span) => {
5216
- const liveEntries = await listProjects().catch(() => []);
5217
- const target = routeSpanToProject(span.service, liveEntries);
5218
- const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5219
- if (!slot || slot.status !== "active") return;
5322
+ const slot = await resolveTargetSlot(span.service);
5323
+ if (!slot) return;
5220
5324
  await handleSpan(
5221
5325
  {
5222
5326
  graph: slot.graph,
@@ -5229,10 +5333,8 @@ async function startDaemon(opts = {}) {
5229
5333
  );
5230
5334
  },
5231
5335
  onErrorSpanSync: async (span) => {
5232
- const liveEntries = await listProjects().catch(() => []);
5233
- const target = routeSpanToProject(span.service, liveEntries);
5234
- const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5235
- if (!slot || slot.status !== "active") return;
5336
+ const slot = await resolveTargetSlot(span.service);
5337
+ if (!slot) return;
5236
5338
  await makeErrorSpanWriter(slot.paths.errorsPath)(span);
5237
5339
  }
5238
5340
  });
@@ -5389,7 +5491,83 @@ async function spawnWebUI(restPort) {
5389
5491
  return { child, port, stop };
5390
5492
  }
5391
5493
 
5494
+ // src/version-skew.ts
5495
+ init_cjs_shims();
5496
+ var import_promises = require("timers/promises");
5497
+ var NPM_REGISTRY_URL = "https://registry.npmjs.org/neat.is/latest";
5498
+ var DEFAULT_TIMEOUT_MS = 2e3;
5499
+ async function fetchRegistryVersion(url, timeoutMs, fetchImpl) {
5500
+ const controller = new AbortController();
5501
+ let timedOut = false;
5502
+ const timer = (0, import_promises.setTimeout)(timeoutMs).then(() => {
5503
+ timedOut = true;
5504
+ controller.abort();
5505
+ });
5506
+ try {
5507
+ const res = await Promise.race([
5508
+ fetchImpl(url, { signal: controller.signal, headers: { accept: "application/json" } }),
5509
+ timer.then(() => null)
5510
+ ]);
5511
+ if (timedOut || !res) return null;
5512
+ if (!res.ok) return null;
5513
+ const body = await res.json();
5514
+ if (typeof body.version !== "string" || body.version.length === 0) return null;
5515
+ return body.version;
5516
+ } catch {
5517
+ return null;
5518
+ } finally {
5519
+ if (!timedOut) controller.abort();
5520
+ }
5521
+ }
5522
+ function isLocalBehind(local, remote) {
5523
+ if (local === remote) return false;
5524
+ const parse10 = (v) => {
5525
+ const core = v.split(/[-+]/)[0] ?? v;
5526
+ return core.split(".").map((s) => {
5527
+ const n = Number.parseInt(s, 10);
5528
+ return Number.isFinite(n) ? n : 0;
5529
+ });
5530
+ };
5531
+ const a = parse10(local);
5532
+ const b = parse10(remote);
5533
+ const len = Math.max(a.length, b.length);
5534
+ for (let i = 0; i < len; i++) {
5535
+ const av = a[i] ?? 0;
5536
+ const bv = b[i] ?? 0;
5537
+ if (av < bv) return true;
5538
+ if (av > bv) return false;
5539
+ }
5540
+ return false;
5541
+ }
5542
+ async function checkVersionSkew(opts) {
5543
+ const url = opts.registryUrl ?? NPM_REGISTRY_URL;
5544
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
5545
+ const fetchImpl = opts.fetchImpl ?? fetch;
5546
+ const warn = opts.warn ?? ((m) => console.warn(m));
5547
+ const remote = await fetchRegistryVersion(url, timeoutMs, fetchImpl);
5548
+ if (!remote) return { remoteVersion: null, skewed: false, warned: false };
5549
+ if (!isLocalBehind(opts.localVersion, remote)) {
5550
+ return { remoteVersion: remote, skewed: false, warned: false };
5551
+ }
5552
+ warn(
5553
+ `[neatd] running neat.is@${opts.localVersion} but neat.is@${remote} is on npm \u2014 run \`npm install -g neat.is@latest\` to update`
5554
+ );
5555
+ return { remoteVersion: remote, skewed: true, warned: true };
5556
+ }
5557
+
5392
5558
  // src/neatd.ts
5559
+ function localVersion() {
5560
+ if (process.env.NEAT_LOCAL_VERSION && process.env.NEAT_LOCAL_VERSION.length > 0) {
5561
+ return process.env.NEAT_LOCAL_VERSION;
5562
+ }
5563
+ try {
5564
+ const req2 = (0, import_node_module.createRequire)(importMetaUrl);
5565
+ const pkg = req2("../package.json");
5566
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
5567
+ } catch {
5568
+ return "0.0.0";
5569
+ }
5570
+ }
5393
5571
  function neatHome2() {
5394
5572
  if (process.env.NEAT_HOME && process.env.NEAT_HOME.length > 0) {
5395
5573
  return import_node_path38.default.resolve(process.env.NEAT_HOME);
@@ -5419,6 +5597,10 @@ async function cmdStart() {
5419
5597
  console.log(`neatd: registry at ${registryPath()}`);
5420
5598
  if (handle.restAddress) console.log(`neatd: REST \u2192 ${handle.restAddress}`);
5421
5599
  if (handle.otlpAddress) console.log(`neatd: OTLP \u2192 ${handle.otlpAddress}`);
5600
+ if (process.env.NEAT_DISABLE_VERSION_CHECK !== "1") {
5601
+ void checkVersionSkew({ localVersion: localVersion() }).catch(() => {
5602
+ });
5603
+ }
5422
5604
  const skipWeb = process.env.NEAT_WEB_DISABLED === "1";
5423
5605
  let web = null;
5424
5606
  if (!skipWeb) {