@neat.is/core 0.4.3 → 0.4.5

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
@@ -130,6 +130,7 @@ declare function buildApi(opts: BuildApiOptions): Promise<FastifyInstance>;
130
130
 
131
131
  interface ParsedSpan {
132
132
  service: string;
133
+ resourceServiceNamePresent?: boolean;
133
134
  traceId: string;
134
135
  spanId: string;
135
136
  parentSpanId?: string;
@@ -153,9 +154,12 @@ interface ParsedSpan {
153
154
  }
154
155
  type AttributeValue = string | number | boolean | bigint | string[] | number[] | boolean[] | null;
155
156
  type SpanHandler = (span: ParsedSpan) => void | Promise<void>;
157
+ type ProjectSpanHandler = (project: string, span: ParsedSpan) => void | Promise<void>;
156
158
  interface BuildOtelReceiverOptions {
157
159
  onSpan: SpanHandler;
158
160
  onErrorSpanSync?: (span: ParsedSpan) => Promise<void>;
161
+ onProjectSpan?: ProjectSpanHandler;
162
+ onProjectErrorSpanSync?: (project: string, span: ParsedSpan) => Promise<void>;
159
163
  bodyLimit?: number;
160
164
  authToken?: string;
161
165
  trustProxy?: boolean;
package/dist/index.d.ts CHANGED
@@ -130,6 +130,7 @@ declare function buildApi(opts: BuildApiOptions): Promise<FastifyInstance>;
130
130
 
131
131
  interface ParsedSpan {
132
132
  service: string;
133
+ resourceServiceNamePresent?: boolean;
133
134
  traceId: string;
134
135
  spanId: string;
135
136
  parentSpanId?: string;
@@ -153,9 +154,12 @@ interface ParsedSpan {
153
154
  }
154
155
  type AttributeValue = string | number | boolean | bigint | string[] | number[] | boolean[] | null;
155
156
  type SpanHandler = (span: ParsedSpan) => void | Promise<void>;
157
+ type ProjectSpanHandler = (project: string, span: ParsedSpan) => void | Promise<void>;
156
158
  interface BuildOtelReceiverOptions {
157
159
  onSpan: SpanHandler;
158
160
  onErrorSpanSync?: (span: ParsedSpan) => Promise<void>;
161
+ onProjectSpan?: ProjectSpanHandler;
162
+ onProjectErrorSpanSync?: (project: string, span: ParsedSpan) => Promise<void>;
159
163
  bodyLimit?: number;
160
164
  authToken?: string;
161
165
  trustProxy?: boolean;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  routeSpanToProject,
3
3
  startDaemon
4
- } from "./chunk-CB2UK4EH.js";
4
+ } from "./chunk-6GXBAR3M.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-NTQHMXWE.js";
40
+ } from "./chunk-RBWL4HRB.js";
41
41
  import {
42
42
  startOtelGrpcReceiver
43
- } from "./chunk-D5PIJFBE.js";
43
+ } from "./chunk-3QCRUEQD.js";
44
44
  import {
45
45
  buildOtelReceiver,
46
46
  logSpanHandler,
47
47
  parseOtlpRequest
48
- } from "./chunk-KYRIQIPG.js";
48
+ } from "./chunk-HVF4S7J3.js";
49
49
  export {
50
50
  ProjectNameCollisionError,
51
51
  addProject,
package/dist/neatd.cjs CHANGED
@@ -337,12 +337,15 @@ function parseOtlpRequest(body) {
337
337
  const out = [];
338
338
  for (const rs of body.resourceSpans ?? []) {
339
339
  const resourceAttrs = attrsToRecord(rs.resource?.attributes);
340
- const service = typeof resourceAttrs["service.name"] === "string" ? resourceAttrs["service.name"] : "unknown";
340
+ const rawServiceName = resourceAttrs["service.name"];
341
+ const resourceServiceNamePresent = typeof rawServiceName === "string" && rawServiceName.length > 0;
342
+ const service = resourceServiceNamePresent ? rawServiceName : "unidentified";
341
343
  for (const ss of rs.scopeSpans ?? []) {
342
344
  for (const span of ss.spans ?? []) {
343
345
  const attrs = attrsToRecord(span.attributes);
344
346
  const parsed = {
345
347
  service,
348
+ resourceServiceNamePresent,
346
349
  traceId: span.traceId ?? "",
347
350
  spanId: span.spanId ?? "",
348
351
  parentSpanId: span.parentSpanId || void 0,
@@ -437,6 +440,64 @@ async function buildOtelReceiver(opts) {
437
440
  for (const s of spans) queue.push(s);
438
441
  drainPromise = drainPromise.then(() => drain());
439
442
  };
443
+ const projectQueue = [];
444
+ let projectDraining = false;
445
+ let projectDrainPromise = Promise.resolve();
446
+ const drainProject = async () => {
447
+ if (projectDraining) return;
448
+ projectDraining = true;
449
+ try {
450
+ while (projectQueue.length > 0) {
451
+ const { project, span } = projectQueue.shift();
452
+ try {
453
+ if (opts.onProjectSpan) {
454
+ await opts.onProjectSpan(project, span);
455
+ } else {
456
+ await opts.onSpan(span);
457
+ }
458
+ } catch (err) {
459
+ console.warn(`[neat] otel handler error: ${err.message}`);
460
+ }
461
+ }
462
+ } finally {
463
+ projectDraining = false;
464
+ }
465
+ };
466
+ const enqueueProject = (project, spans) => {
467
+ if (spans.length === 0) return;
468
+ for (const s of spans) projectQueue.push({ project, span: s });
469
+ projectDrainPromise = projectDrainPromise.then(() => drainProject());
470
+ };
471
+ const legacyEndpointWarned = /* @__PURE__ */ new Set();
472
+ function warnLegacyEndpoint(serviceName) {
473
+ if (legacyEndpointWarned.has(serviceName)) return;
474
+ legacyEndpointWarned.add(serviceName);
475
+ console.warn(
476
+ `[neatd] received span on the global endpoint; migrate OTEL_EXPORTER_OTLP_TRACES_ENDPOINT to /projects/<name>/v1/traces (service.name="${serviceName}").`
477
+ );
478
+ }
479
+ async function readOtlpBody(req2) {
480
+ const ct = (req2.headers["content-type"] ?? "").toString().split(";")[0].trim().toLowerCase();
481
+ if (ct === "application/x-protobuf") {
482
+ try {
483
+ const body = await decodeProtobufBody(req2.body);
484
+ return { ok: true, body, flavor: "protobuf" };
485
+ } catch (err) {
486
+ return { ok: false, code: 400, error: `protobuf decode failed: ${err.message}` };
487
+ }
488
+ }
489
+ if (!ct || ct === "application/json") {
490
+ return { ok: true, body: req2.body ?? {}, flavor: "json" };
491
+ }
492
+ return { ok: false, code: 415, error: `unsupported content-type: ${ct}` };
493
+ }
494
+ function sendOtlpSuccess(reply, flavor) {
495
+ if (flavor === "protobuf") {
496
+ const buf = encodeProtobufResponseBody();
497
+ return reply.code(200).header("content-type", "application/x-protobuf").send(buf);
498
+ }
499
+ return reply.code(200).header("content-type", "application/json").send({ partialSuccess: {} });
500
+ }
440
501
  app.addContentTypeParser(
441
502
  "application/x-protobuf",
442
503
  { parseAs: "buffer", bodyLimit: opts.bodyLimit ?? 16 * 1024 * 1024 },
@@ -446,26 +507,44 @@ async function buildOtelReceiver(opts) {
446
507
  );
447
508
  app.get("/health", async () => ({ ok: true }));
448
509
  app.post("/v1/traces", async (req2, reply) => {
449
- const ct = (req2.headers["content-type"] ?? "").toString().split(";")[0].trim().toLowerCase();
450
- let body;
451
- let responseFlavor;
452
- if (ct === "application/x-protobuf") {
453
- responseFlavor = "protobuf";
510
+ const result = await readOtlpBody(req2);
511
+ if (!result.ok) {
512
+ return reply.code(result.code).send({ error: result.error });
513
+ }
514
+ const spans = parseOtlpRequest(result.body);
515
+ for (const s of spans) warnLegacyEndpoint(s.service);
516
+ if (opts.onErrorSpanSync) {
454
517
  try {
455
- body = await decodeProtobufBody(req2.body);
518
+ for (const span of spans) {
519
+ if (span.statusCode === 2) await opts.onErrorSpanSync(span);
520
+ }
456
521
  } catch (err) {
457
- return reply.code(400).send({
458
- error: `protobuf decode failed: ${err.message}`
522
+ return reply.code(500).send({
523
+ error: `error-event write failed: ${err.message}`
459
524
  });
460
525
  }
461
- } else if (!ct || ct === "application/json") {
462
- responseFlavor = "json";
463
- body = req2.body ?? {};
464
- } else {
465
- return reply.code(415).send({ error: `unsupported content-type: ${ct}` });
466
526
  }
467
- const spans = parseOtlpRequest(body);
468
- if (opts.onErrorSpanSync) {
527
+ enqueue(spans);
528
+ return sendOtlpSuccess(reply, result.flavor);
529
+ });
530
+ app.post("/projects/:project/v1/traces", async (req2, reply) => {
531
+ const project = req2.params.project;
532
+ const result = await readOtlpBody(req2);
533
+ if (!result.ok) {
534
+ return reply.code(result.code).send({ error: result.error });
535
+ }
536
+ const spans = parseOtlpRequest(result.body);
537
+ if (opts.onProjectErrorSpanSync) {
538
+ try {
539
+ for (const span of spans) {
540
+ if (span.statusCode === 2) await opts.onProjectErrorSpanSync(project, span);
541
+ }
542
+ } catch (err) {
543
+ return reply.code(500).send({
544
+ error: `error-event write failed: ${err.message}`
545
+ });
546
+ }
547
+ } else if (opts.onErrorSpanSync) {
469
548
  try {
470
549
  for (const span of spans) {
471
550
  if (span.statusCode === 2) await opts.onErrorSpanSync(span);
@@ -476,17 +555,13 @@ async function buildOtelReceiver(opts) {
476
555
  });
477
556
  }
478
557
  }
479
- enqueue(spans);
480
- if (responseFlavor === "protobuf") {
481
- const buf = encodeProtobufResponseBody();
482
- return reply.code(200).header("content-type", "application/x-protobuf").send(buf);
483
- }
484
- return reply.code(200).header("content-type", "application/json").send({ partialSuccess: {} });
558
+ enqueueProject(project, spans);
559
+ return sendOtlpSuccess(reply, result.flavor);
485
560
  });
486
561
  const decorated = app;
487
562
  decorated.flushPending = async () => {
488
- while (queue.length > 0 || draining) {
489
- await drainPromise;
563
+ while (queue.length > 0 || draining || projectQueue.length > 0 || projectDraining) {
564
+ await Promise.all([drainPromise, projectDrainPromise]);
490
565
  }
491
566
  };
492
567
  return decorated;
@@ -1506,6 +1581,14 @@ var DEFAULT_STALE_THRESHOLDS = {
1506
1581
  function nowIso(ctx) {
1507
1582
  return new Date(ctx.now ? ctx.now() : Date.now()).toISOString();
1508
1583
  }
1584
+ var unidentifiedWarnedProjects = /* @__PURE__ */ new Set();
1585
+ function warnUnidentifiedSpan(project) {
1586
+ if (unidentifiedWarnedProjects.has(project)) return;
1587
+ unidentifiedWarnedProjects.add(project);
1588
+ console.warn(
1589
+ `[neatd] span lacked service.name; routed to 'unidentified' in project ${project}; check your OTel SDK config.`
1590
+ );
1591
+ }
1509
1592
  function pickAttr(span, ...keys) {
1510
1593
  for (const k of keys) {
1511
1594
  const v = span.attributes[k];
@@ -1760,6 +1843,9 @@ async function handleSpan(ctx, span) {
1760
1843
  const ts = span.startTimeIso ?? nowIso(ctx);
1761
1844
  const nowMs = ctx.now ? ctx.now() : Date.now();
1762
1845
  const env = span.env ?? "unknown";
1846
+ if (span.resourceServiceNamePresent === false) {
1847
+ warnUnidentifiedSpan(ctx.project ?? DEFAULT_PROJECT);
1848
+ }
1763
1849
  const sourceId = ensureServiceNode(ctx.graph, span.service, env);
1764
1850
  const isError = span.statusCode === 2;
1765
1851
  cacheSpanService(span, nowMs);
@@ -5680,6 +5766,25 @@ async function startDaemon(opts = {}) {
5680
5766
  }
5681
5767
  return slot.status === "active" ? slot : null;
5682
5768
  }
5769
+ async function resolveSlotByName(project, serviceName, traceId) {
5770
+ const liveEntries = await listProjects().catch(() => []);
5771
+ let slot = slots.get(project);
5772
+ if (!slot) {
5773
+ await recordUnroutedSpan(serviceName, traceId);
5774
+ return null;
5775
+ }
5776
+ if (slot.status === "broken") {
5777
+ const entry2 = liveEntries.find((e) => e.name === slot.entry.name);
5778
+ if (entry2) {
5779
+ slot = await tryRecoverSlot(entry2);
5780
+ }
5781
+ if (slot.status !== "active") {
5782
+ warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
5783
+ return null;
5784
+ }
5785
+ }
5786
+ return slot.status === "active" ? slot : null;
5787
+ }
5683
5788
  try {
5684
5789
  otlpApp = await buildOtelReceiver({
5685
5790
  authToken: auth.otelToken,
@@ -5702,6 +5807,28 @@ async function startDaemon(opts = {}) {
5702
5807
  const slot = await resolveTargetSlot(span.service, span.traceId);
5703
5808
  if (!slot) return;
5704
5809
  await makeErrorSpanWriter(slot.paths.errorsPath)(span);
5810
+ },
5811
+ // Project-scoped route (issue #367) — the URL already named the
5812
+ // project. Resolution is a direct slot lookup; service.name resolves
5813
+ // the ServiceNode inside the slot's graph instead of which project
5814
+ // owns the span.
5815
+ onProjectSpan: async (project, span) => {
5816
+ const slot = await resolveSlotByName(project, span.service, span.traceId);
5817
+ if (!slot) return;
5818
+ await handleSpan(
5819
+ {
5820
+ graph: slot.graph,
5821
+ errorsPath: slot.paths.errorsPath,
5822
+ project: slot.entry.name,
5823
+ writeErrorEventInline: false
5824
+ },
5825
+ span
5826
+ );
5827
+ },
5828
+ onProjectErrorSpanSync: async (project, span) => {
5829
+ const slot = await resolveSlotByName(project, span.service, span.traceId);
5830
+ if (!slot) return;
5831
+ await makeErrorSpanWriter(slot.paths.errorsPath)(span);
5705
5832
  }
5706
5833
  });
5707
5834
  otlpAddress = await otlpApp.listen({ port: otlpPort, host });