@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/{chunk-D5PIJFBE.js → chunk-3QCRUEQD.js} +2 -2
- package/dist/{chunk-CB2UK4EH.js → chunk-6GXBAR3M.js} +44 -3
- package/dist/{chunk-CB2UK4EH.js.map → chunk-6GXBAR3M.js.map} +1 -1
- package/dist/{chunk-KYRIQIPG.js → chunk-HVF4S7J3.js} +101 -26
- package/dist/chunk-HVF4S7J3.js.map +1 -0
- package/dist/{chunk-NTQHMXWE.js → chunk-RBWL4HRB.js} +13 -2
- package/dist/chunk-RBWL4HRB.js.map +1 -0
- package/dist/cli.cjs +430 -124
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +323 -103
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +151 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -4
- package/dist/neatd.cjs +151 -24
- package/dist/neatd.cjs.map +1 -1
- package/dist/neatd.js +3 -3
- package/dist/{otel-grpc-QTX2YQJZ.js → otel-grpc-VSPMP3OS.js} +3 -3
- package/dist/server.cjs +110 -24
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +3 -3
- package/package.json +2 -2
- package/dist/chunk-KYRIQIPG.js.map +0 -1
- package/dist/chunk-NTQHMXWE.js.map +0 -1
- /package/dist/{chunk-D5PIJFBE.js.map → chunk-3QCRUEQD.js.map} +0 -0
- /package/dist/{otel-grpc-QTX2YQJZ.js.map → otel-grpc-VSPMP3OS.js.map} +0 -0
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-
|
|
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-
|
|
40
|
+
} from "./chunk-RBWL4HRB.js";
|
|
41
41
|
import {
|
|
42
42
|
startOtelGrpcReceiver
|
|
43
|
-
} from "./chunk-
|
|
43
|
+
} from "./chunk-3QCRUEQD.js";
|
|
44
44
|
import {
|
|
45
45
|
buildOtelReceiver,
|
|
46
46
|
logSpanHandler,
|
|
47
47
|
parseOtlpRequest
|
|
48
|
-
} from "./chunk-
|
|
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
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
518
|
+
for (const span of spans) {
|
|
519
|
+
if (span.statusCode === 2) await opts.onErrorSpanSync(span);
|
|
520
|
+
}
|
|
456
521
|
} catch (err) {
|
|
457
|
-
return reply.code(
|
|
458
|
-
error: `
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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 });
|