@neat.is/core 0.3.6 → 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-YHQYHFI3.js";
4
+ } from "./chunk-EDHOCFOG.js";
5
5
  import {
6
6
  ProjectNameCollisionError,
7
7
  addProject,
@@ -40,12 +40,12 @@ import {
40
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
 
@@ -5055,13 +5084,35 @@ function neatHomeFor(opts) {
5055
5084
  function routeSpanToProject(serviceName, projects) {
5056
5085
  if (!serviceName) return DEFAULT_PROJECT;
5057
5086
  for (const entry2 of projects) {
5058
- if (entry2.status !== "active") continue;
5059
- if (entry2.languages.length === 0) {
5060
- }
5087
+ if (entry2.status === "paused") continue;
5061
5088
  if (entry2.name === serviceName) return entry2.name;
5062
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
+ }
5063
5103
  return DEFAULT_PROJECT;
5064
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
+ }
5065
5116
  async function bootstrapProject(entry2) {
5066
5117
  const paths = pathsForProject(entry2.name, import_node_path36.default.join(entry2.path, "neat-out"));
5067
5118
  try {
@@ -5141,6 +5192,17 @@ async function startDaemon(opts = {}) {
5141
5192
  `);
5142
5193
  const slots = /* @__PURE__ */ new Map();
5143
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
+ }
5144
5206
  function upsertRegistryFromSlot(slot) {
5145
5207
  if (slot.status !== "active") return;
5146
5208
  registry.set(slot.entry.name, {
@@ -5149,12 +5211,38 @@ async function startDaemon(opts = {}) {
5149
5211
  graph: slot.graph
5150
5212
  });
5151
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
+ }
5152
5234
  async function loadAll() {
5153
5235
  const projects = await listProjects();
5154
5236
  const seen = /* @__PURE__ */ new Set();
5155
5237
  for (const entry2 of projects) {
5156
5238
  seen.add(entry2.name);
5157
- 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
+ }
5158
5246
  try {
5159
5247
  const slot = await bootstrapProject(entry2);
5160
5248
  slots.set(entry2.name, slot);
@@ -5211,13 +5299,28 @@ async function startDaemon(opts = {}) {
5211
5299
  `neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
5212
5300
  );
5213
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
+ }
5214
5319
  try {
5215
5320
  otlpApp = await buildOtelReceiver({
5216
5321
  onSpan: async (span) => {
5217
- const liveEntries = await listProjects().catch(() => []);
5218
- const target = routeSpanToProject(span.service, liveEntries);
5219
- const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5220
- if (!slot || slot.status !== "active") return;
5322
+ const slot = await resolveTargetSlot(span.service);
5323
+ if (!slot) return;
5221
5324
  await handleSpan(
5222
5325
  {
5223
5326
  graph: slot.graph,
@@ -5230,10 +5333,8 @@ async function startDaemon(opts = {}) {
5230
5333
  );
5231
5334
  },
5232
5335
  onErrorSpanSync: async (span) => {
5233
- const liveEntries = await listProjects().catch(() => []);
5234
- const target = routeSpanToProject(span.service, liveEntries);
5235
- const slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
5236
- if (!slot || slot.status !== "active") return;
5336
+ const slot = await resolveTargetSlot(span.service);
5337
+ if (!slot) return;
5237
5338
  await makeErrorSpanWriter(slot.paths.errorsPath)(span);
5238
5339
  }
5239
5340
  });