@neat.is/core 0.4.3 → 0.4.4

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
@@ -437,6 +437,64 @@ async function buildOtelReceiver(opts) {
437
437
  for (const s of spans) queue.push(s);
438
438
  drainPromise = drainPromise.then(() => drain());
439
439
  };
440
+ const projectQueue = [];
441
+ let projectDraining = false;
442
+ let projectDrainPromise = Promise.resolve();
443
+ const drainProject = async () => {
444
+ if (projectDraining) return;
445
+ projectDraining = true;
446
+ try {
447
+ while (projectQueue.length > 0) {
448
+ const { project, span } = projectQueue.shift();
449
+ try {
450
+ if (opts.onProjectSpan) {
451
+ await opts.onProjectSpan(project, span);
452
+ } else {
453
+ await opts.onSpan(span);
454
+ }
455
+ } catch (err) {
456
+ console.warn(`[neat] otel handler error: ${err.message}`);
457
+ }
458
+ }
459
+ } finally {
460
+ projectDraining = false;
461
+ }
462
+ };
463
+ const enqueueProject = (project, spans) => {
464
+ if (spans.length === 0) return;
465
+ for (const s of spans) projectQueue.push({ project, span: s });
466
+ projectDrainPromise = projectDrainPromise.then(() => drainProject());
467
+ };
468
+ const legacyEndpointWarned = /* @__PURE__ */ new Set();
469
+ function warnLegacyEndpoint(serviceName) {
470
+ if (legacyEndpointWarned.has(serviceName)) return;
471
+ legacyEndpointWarned.add(serviceName);
472
+ console.warn(
473
+ `[neatd] received span on the global endpoint; migrate OTEL_EXPORTER_OTLP_TRACES_ENDPOINT to /projects/<name>/v1/traces (service.name="${serviceName}").`
474
+ );
475
+ }
476
+ async function readOtlpBody(req) {
477
+ const ct = (req.headers["content-type"] ?? "").toString().split(";")[0].trim().toLowerCase();
478
+ if (ct === "application/x-protobuf") {
479
+ try {
480
+ const body = await decodeProtobufBody(req.body);
481
+ return { ok: true, body, flavor: "protobuf" };
482
+ } catch (err) {
483
+ return { ok: false, code: 400, error: `protobuf decode failed: ${err.message}` };
484
+ }
485
+ }
486
+ if (!ct || ct === "application/json") {
487
+ return { ok: true, body: req.body ?? {}, flavor: "json" };
488
+ }
489
+ return { ok: false, code: 415, error: `unsupported content-type: ${ct}` };
490
+ }
491
+ function sendOtlpSuccess(reply, flavor) {
492
+ if (flavor === "protobuf") {
493
+ const buf = encodeProtobufResponseBody();
494
+ return reply.code(200).header("content-type", "application/x-protobuf").send(buf);
495
+ }
496
+ return reply.code(200).header("content-type", "application/json").send({ partialSuccess: {} });
497
+ }
440
498
  app.addContentTypeParser(
441
499
  "application/x-protobuf",
442
500
  { parseAs: "buffer", bodyLimit: opts.bodyLimit ?? 16 * 1024 * 1024 },
@@ -446,26 +504,44 @@ async function buildOtelReceiver(opts) {
446
504
  );
447
505
  app.get("/health", async () => ({ ok: true }));
448
506
  app.post("/v1/traces", async (req, reply) => {
449
- const ct = (req.headers["content-type"] ?? "").toString().split(";")[0].trim().toLowerCase();
450
- let body;
451
- let responseFlavor;
452
- if (ct === "application/x-protobuf") {
453
- responseFlavor = "protobuf";
507
+ const result = await readOtlpBody(req);
508
+ if (!result.ok) {
509
+ return reply.code(result.code).send({ error: result.error });
510
+ }
511
+ const spans = parseOtlpRequest(result.body);
512
+ for (const s of spans) warnLegacyEndpoint(s.service);
513
+ if (opts.onErrorSpanSync) {
454
514
  try {
455
- body = await decodeProtobufBody(req.body);
515
+ for (const span of spans) {
516
+ if (span.statusCode === 2) await opts.onErrorSpanSync(span);
517
+ }
456
518
  } catch (err) {
457
- return reply.code(400).send({
458
- error: `protobuf decode failed: ${err.message}`
519
+ return reply.code(500).send({
520
+ error: `error-event write failed: ${err.message}`
459
521
  });
460
522
  }
461
- } else if (!ct || ct === "application/json") {
462
- responseFlavor = "json";
463
- body = req.body ?? {};
464
- } else {
465
- return reply.code(415).send({ error: `unsupported content-type: ${ct}` });
466
523
  }
467
- const spans = parseOtlpRequest(body);
468
- if (opts.onErrorSpanSync) {
524
+ enqueue(spans);
525
+ return sendOtlpSuccess(reply, result.flavor);
526
+ });
527
+ app.post("/projects/:project/v1/traces", async (req, reply) => {
528
+ const project = req.params.project;
529
+ const result = await readOtlpBody(req);
530
+ if (!result.ok) {
531
+ return reply.code(result.code).send({ error: result.error });
532
+ }
533
+ const spans = parseOtlpRequest(result.body);
534
+ if (opts.onProjectErrorSpanSync) {
535
+ try {
536
+ for (const span of spans) {
537
+ if (span.statusCode === 2) await opts.onProjectErrorSpanSync(project, span);
538
+ }
539
+ } catch (err) {
540
+ return reply.code(500).send({
541
+ error: `error-event write failed: ${err.message}`
542
+ });
543
+ }
544
+ } else if (opts.onErrorSpanSync) {
469
545
  try {
470
546
  for (const span of spans) {
471
547
  if (span.statusCode === 2) await opts.onErrorSpanSync(span);
@@ -476,17 +552,13 @@ async function buildOtelReceiver(opts) {
476
552
  });
477
553
  }
478
554
  }
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: {} });
555
+ enqueueProject(project, spans);
556
+ return sendOtlpSuccess(reply, result.flavor);
485
557
  });
486
558
  const decorated = app;
487
559
  decorated.flushPending = async () => {
488
- while (queue.length > 0 || draining) {
489
- await drainPromise;
560
+ while (queue.length > 0 || draining || projectQueue.length > 0 || projectDraining) {
561
+ await Promise.all([drainPromise, projectDrainPromise]);
490
562
  }
491
563
  };
492
564
  return decorated;
@@ -5884,6 +5956,25 @@ async function startDaemon(opts = {}) {
5884
5956
  }
5885
5957
  return slot.status === "active" ? slot : null;
5886
5958
  }
5959
+ async function resolveSlotByName(project, serviceName, traceId) {
5960
+ const liveEntries = await listProjects().catch(() => []);
5961
+ let slot = slots.get(project);
5962
+ if (!slot) {
5963
+ await recordUnroutedSpan(serviceName, traceId);
5964
+ return null;
5965
+ }
5966
+ if (slot.status === "broken") {
5967
+ const entry = liveEntries.find((e) => e.name === slot.entry.name);
5968
+ if (entry) {
5969
+ slot = await tryRecoverSlot(entry);
5970
+ }
5971
+ if (slot.status !== "active") {
5972
+ warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
5973
+ return null;
5974
+ }
5975
+ }
5976
+ return slot.status === "active" ? slot : null;
5977
+ }
5887
5978
  try {
5888
5979
  otlpApp = await buildOtelReceiver({
5889
5980
  authToken: auth.otelToken,
@@ -5906,6 +5997,28 @@ async function startDaemon(opts = {}) {
5906
5997
  const slot = await resolveTargetSlot(span.service, span.traceId);
5907
5998
  if (!slot) return;
5908
5999
  await makeErrorSpanWriter(slot.paths.errorsPath)(span);
6000
+ },
6001
+ // Project-scoped route (issue #367) — the URL already named the
6002
+ // project. Resolution is a direct slot lookup; service.name resolves
6003
+ // the ServiceNode inside the slot's graph instead of which project
6004
+ // owns the span.
6005
+ onProjectSpan: async (project, span) => {
6006
+ const slot = await resolveSlotByName(project, span.service, span.traceId);
6007
+ if (!slot) return;
6008
+ await handleSpan(
6009
+ {
6010
+ graph: slot.graph,
6011
+ errorsPath: slot.paths.errorsPath,
6012
+ project: slot.entry.name,
6013
+ writeErrorEventInline: false
6014
+ },
6015
+ span
6016
+ );
6017
+ },
6018
+ onProjectErrorSpanSync: async (project, span) => {
6019
+ const slot = await resolveSlotByName(project, span.service, span.traceId);
6020
+ if (!slot) return;
6021
+ await makeErrorSpanWriter(slot.paths.errorsPath)(span);
5909
6022
  }
5910
6023
  });
5911
6024
  otlpAddress = await otlpApp.listen({ port: otlpPort, host });