@shipfox/api-logs 13.0.0 → 14.0.0
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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +16 -0
- package/dist/core/append-logs.d.ts.map +1 -1
- package/dist/core/append-logs.js +15 -2
- package/dist/core/append-logs.js.map +1 -1
- package/dist/db/chunks.d.ts +7 -0
- package/dist/db/chunks.d.ts.map +1 -1
- package/dist/db/chunks.js +11 -0
- package/dist/db/chunks.js.map +1 -1
- package/dist/metrics/instance.d.ts +3 -0
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +33 -0
- package/dist/metrics/instance.js.map +1 -1
- package/dist/metrics/service.d.ts.map +1 -1
- package/dist/metrics/service.js +10 -1
- package/dist/metrics/service.js.map +1 -1
- package/dist/temporal/activities/compact-stream.d.ts.map +1 -1
- package/dist/temporal/activities/compact-stream.js +7 -1
- package/dist/temporal/activities/compact-stream.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/src/core/append-logs.test.ts +153 -0
- package/src/core/append-logs.ts +15 -0
- package/src/db/chunks.test.ts +59 -0
- package/src/db/chunks.ts +14 -0
- package/src/metrics/instance.ts +44 -0
- package/src/metrics/service.ts +11 -1
- package/src/temporal/activities/compact-stream.ts +11 -1
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-logs",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "14.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -27,15 +27,15 @@
|
|
|
27
27
|
"zod": "^4.4.3",
|
|
28
28
|
"@shipfox/api-auth-context": "12.2.0",
|
|
29
29
|
"@shipfox/api-logs-dto": "12.0.0",
|
|
30
|
-
"@shipfox/api-workflows-dto": "
|
|
31
|
-
"@shipfox/workflow-document": "3.0.1",
|
|
30
|
+
"@shipfox/api-workflows-dto": "14.0.0",
|
|
32
31
|
"@shipfox/config": "1.2.4",
|
|
33
|
-
"@shipfox/
|
|
34
|
-
"@shipfox/node-error-monitoring": "0.3.0",
|
|
32
|
+
"@shipfox/workflow-document": "3.1.0",
|
|
35
33
|
"@shipfox/node-fastify": "0.4.2",
|
|
36
|
-
"@shipfox/node-module": "1.0.6",
|
|
37
34
|
"@shipfox/node-opentelemetry": "0.6.4",
|
|
38
35
|
"@shipfox/node-outbox": "0.2.6",
|
|
36
|
+
"@shipfox/node-module": "1.0.6",
|
|
37
|
+
"@shipfox/node-error-monitoring": "0.3.0",
|
|
38
|
+
"@shipfox/node-drizzle": "0.3.5",
|
|
39
39
|
"@shipfox/node-postgres": "0.5.0",
|
|
40
40
|
"@shipfox/node-temporal": "0.4.5"
|
|
41
41
|
},
|
|
@@ -16,6 +16,24 @@ import {
|
|
|
16
16
|
} from '#test/fixtures/ndjson.js';
|
|
17
17
|
import {findAccounting, findStream, listChunks, listStreamClosedEvents} from '#test/queries.js';
|
|
18
18
|
|
|
19
|
+
const metricsMocks = vi.hoisted(() => {
|
|
20
|
+
const counters = new Map<string, {add: ReturnType<typeof vi.fn>}>();
|
|
21
|
+
const add = (name: string) => {
|
|
22
|
+
const counter = {add: vi.fn()};
|
|
23
|
+
counters.set(name, counter);
|
|
24
|
+
return counter;
|
|
25
|
+
};
|
|
26
|
+
return {counters, add};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
vi.mock('#metrics/instance.js', () => ({
|
|
30
|
+
bytesIngestedCount: metricsMocks.add('bytesIngestedCount'),
|
|
31
|
+
bytesStoredCount: metricsMocks.add('bytesStoredCount'),
|
|
32
|
+
recordAppendedCount: metricsMocks.add('recordAppendedCount'),
|
|
33
|
+
streamClosedCount: metricsMocks.add('streamClosedCount'),
|
|
34
|
+
streamOpenedCount: metricsMocks.add('streamOpenedCount'),
|
|
35
|
+
}));
|
|
36
|
+
|
|
19
37
|
interface Ctx {
|
|
20
38
|
jobId: string;
|
|
21
39
|
stepId: string;
|
|
@@ -727,6 +745,141 @@ describe('appendLogs', () => {
|
|
|
727
745
|
});
|
|
728
746
|
});
|
|
729
747
|
|
|
748
|
+
describe('byte volume metrics', () => {
|
|
749
|
+
beforeEach(() => {
|
|
750
|
+
for (const counter of metricsMocks.counters.values()) counter.add.mockClear();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
function ingestedAdd() {
|
|
754
|
+
return metricsMocks.counters.get('bytesIngestedCount')?.add;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function storedAdd() {
|
|
758
|
+
return metricsMocks.counters.get('bytesStoredCount')?.add;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
it('counts raw ingested and normalized stored bytes on an in-order append', async () => {
|
|
762
|
+
const ctx = newCtx();
|
|
763
|
+
const body = ndjsonBody(outputLine('hello\n'));
|
|
764
|
+
|
|
765
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body});
|
|
766
|
+
|
|
767
|
+
expect(ingestedAdd()).toHaveBeenCalledTimes(1);
|
|
768
|
+
expect(ingestedAdd()).toHaveBeenCalledWith(body.length);
|
|
769
|
+
expect(storedAdd()).toHaveBeenCalledTimes(1);
|
|
770
|
+
expect(storedAdd()).toHaveBeenCalledWith(body.length);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('counts normalized stored bytes separately from raw ingested bytes', async () => {
|
|
774
|
+
const ctx = newCtx();
|
|
775
|
+
await allowLargeLogBudget(ctx);
|
|
776
|
+
// A session line is parsed into a view row before storage, so the durable chunk is
|
|
777
|
+
// byte-different from the raw body: the exact point of the raw-vs-normalized split.
|
|
778
|
+
const body = ndjsonBody(sessionLine('{"type":"x"}'));
|
|
779
|
+
|
|
780
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body});
|
|
781
|
+
|
|
782
|
+
expect(ingestedAdd()).toHaveBeenCalledTimes(1);
|
|
783
|
+
expect(ingestedAdd()).toHaveBeenCalledWith(body.length);
|
|
784
|
+
const storedAddMock = storedAdd();
|
|
785
|
+
if (!storedAddMock) throw new Error('Expected bytesStoredCount mock');
|
|
786
|
+
const storedBytes = storedAddMock.mock.calls[0]?.[0];
|
|
787
|
+
expect(typeof storedBytes).toBe('number');
|
|
788
|
+
expect(storedBytes).not.toBe(body.length);
|
|
789
|
+
const stream = await findStream({...ctx, attempt: 1});
|
|
790
|
+
const chunks = await listChunks(stream?.id as string);
|
|
791
|
+
expect(storedBytes).toBe(chunks[0]?.byteLen);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it('does not re-count bytes on a retried append', async () => {
|
|
795
|
+
const ctx = newCtx();
|
|
796
|
+
const body = ndjsonBody(outputLine('hello\n'));
|
|
797
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body});
|
|
798
|
+
|
|
799
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body});
|
|
800
|
+
|
|
801
|
+
expect(ingestedAdd()).toHaveBeenCalledTimes(1);
|
|
802
|
+
expect(ingestedAdd()).toHaveBeenCalledWith(body.length);
|
|
803
|
+
expect(storedAdd()).toHaveBeenCalledTimes(1);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('does not count bytes for a rejected gap append', async () => {
|
|
807
|
+
const ctx = newCtx();
|
|
808
|
+
const body = ndjsonBody(outputLine('hello\n'));
|
|
809
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body});
|
|
810
|
+
|
|
811
|
+
await appendLogs({
|
|
812
|
+
...ctx,
|
|
813
|
+
attempt: 1,
|
|
814
|
+
offset: body.length + 5,
|
|
815
|
+
body: ndjsonBody(outputLine('more\n')),
|
|
816
|
+
}).catch(() => undefined);
|
|
817
|
+
|
|
818
|
+
expect(ingestedAdd()).toHaveBeenCalledTimes(1);
|
|
819
|
+
expect(storedAdd()).toHaveBeenCalledTimes(1);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('does not count bytes dropped on a closed stream', async () => {
|
|
823
|
+
const ctx = newCtx();
|
|
824
|
+
// End-only body so the single stored chunk stays under the 100-byte test budget.
|
|
825
|
+
const end = ndjsonBody(endLine(4));
|
|
826
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body: end});
|
|
827
|
+
|
|
828
|
+
await appendLogs({
|
|
829
|
+
...ctx,
|
|
830
|
+
attempt: 1,
|
|
831
|
+
offset: end.length,
|
|
832
|
+
body: ndjsonBody(outputLine('late\n')),
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
expect(ingestedAdd()).toHaveBeenCalledTimes(1);
|
|
836
|
+
expect(storedAdd()).toHaveBeenCalledTimes(1);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it('counts control records in both ingested and stored byte totals', async () => {
|
|
840
|
+
const ctx = newCtx();
|
|
841
|
+
const body = ndjsonBody(outputLine('hello\n'), groupStartLine('g1', 'Build'), endLine(42));
|
|
842
|
+
|
|
843
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body});
|
|
844
|
+
|
|
845
|
+
expect(ingestedAdd()).toHaveBeenCalledTimes(1);
|
|
846
|
+
expect(ingestedAdd()).toHaveBeenCalledWith(body.length);
|
|
847
|
+
expect(storedAdd()).toHaveBeenCalledTimes(1);
|
|
848
|
+
expect(storedAdd()).toHaveBeenCalledWith(body.length);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('counts a cap-crossing append once and a dropped straggler as ingested only', async () => {
|
|
852
|
+
const ctx = newCtx();
|
|
853
|
+
// 150 payload bytes cross the 100-byte test budget, but the crossing chunk is stored in
|
|
854
|
+
// full; the straggler is accepted-and-dropped, so it must not count as stored.
|
|
855
|
+
const crossing = outputOfBytes(150);
|
|
856
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body: crossing});
|
|
857
|
+
const straggler = ndjsonBody(outputLine('late\n'));
|
|
858
|
+
|
|
859
|
+
await appendLogs({...ctx, attempt: 1, offset: crossing.length, body: straggler});
|
|
860
|
+
|
|
861
|
+
expect(ingestedAdd()).toHaveBeenCalledTimes(2);
|
|
862
|
+
expect(ingestedAdd()).toHaveBeenCalledWith(crossing.length);
|
|
863
|
+
expect(ingestedAdd()).toHaveBeenCalledWith(straggler.length);
|
|
864
|
+
// The server-injected `capped` tombstone chunk never counts as stored bytes either.
|
|
865
|
+
expect(storedAdd()).toHaveBeenCalledTimes(1);
|
|
866
|
+
expect(storedAdd()).toHaveBeenCalledWith(crossing.length);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('does not re-count a capped-job straggler when the runner retries it', async () => {
|
|
870
|
+
const ctx = newCtx();
|
|
871
|
+
const crossing = outputOfBytes(150);
|
|
872
|
+
await appendLogs({...ctx, attempt: 1, offset: 0, body: crossing});
|
|
873
|
+
const straggler = ndjsonBody(outputLine('late\n'));
|
|
874
|
+
await appendLogs({...ctx, attempt: 1, offset: crossing.length, body: straggler});
|
|
875
|
+
|
|
876
|
+
await appendLogs({...ctx, attempt: 1, offset: crossing.length, body: straggler});
|
|
877
|
+
|
|
878
|
+
expect(ingestedAdd()).toHaveBeenCalledTimes(2);
|
|
879
|
+
expect(storedAdd()).toHaveBeenCalledTimes(1);
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
|
|
730
883
|
describe('write-path enforcement', () => {
|
|
731
884
|
it.each([
|
|
732
885
|
'capped',
|
package/src/core/append-logs.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
setDeclaredTotalBytes,
|
|
23
23
|
} from '#db/streams.js';
|
|
24
24
|
import {
|
|
25
|
+
bytesIngestedCount,
|
|
26
|
+
bytesStoredCount,
|
|
25
27
|
type LogRecordMetricKind,
|
|
26
28
|
recordAppendedCount,
|
|
27
29
|
streamClosedCount,
|
|
@@ -427,6 +429,12 @@ export async function appendLogs(
|
|
|
427
429
|
recordCounts: {} as Partial<Record<LogRecordMetricKind, number>>,
|
|
428
430
|
streamClosedReason: undefined as 'declared' | undefined,
|
|
429
431
|
streamOpened: false,
|
|
432
|
+
// Raw runner body bytes accepted by the in-order CAS; normalized durable bytes written
|
|
433
|
+
// to chunk rows. Both are accumulated inside the transaction and recorded only after it
|
|
434
|
+
// commits, so a rolled-back append never counts. See the semantics on the metric
|
|
435
|
+
// definitions in `#metrics/instance.js`.
|
|
436
|
+
ingestedBytes: 0,
|
|
437
|
+
storedBytes: 0,
|
|
430
438
|
};
|
|
431
439
|
|
|
432
440
|
const result = await db().transaction(async (tx) => {
|
|
@@ -458,6 +466,9 @@ export async function appendLogs(
|
|
|
458
466
|
if (cas.outcome === 'retry') {
|
|
459
467
|
return {committedLength: cas.committedLength, capped: await isJobCapped(tx, params.jobId)};
|
|
460
468
|
}
|
|
469
|
+
// In-order CAS extension: the raw body is accepted. Retries and gaps returned above, and
|
|
470
|
+
// closed streams / empty heartbeats never reach the CAS, so each body is counted once.
|
|
471
|
+
metrics.ingestedBytes += commitByteLen;
|
|
461
472
|
|
|
462
473
|
const parseHarness =
|
|
463
474
|
sessionHarness === 'claude' ||
|
|
@@ -491,6 +502,8 @@ export async function appendLogs(
|
|
|
491
502
|
declaredTotalBytes,
|
|
492
503
|
});
|
|
493
504
|
if (chunkStored) {
|
|
505
|
+
// Normalized durable bytes; a cap-dropped straggler never reaches this branch.
|
|
506
|
+
metrics.storedBytes += stored.body.length;
|
|
494
507
|
addRecordCounts(metrics.recordCounts, stored.recordCounts);
|
|
495
508
|
if (stored.claudeParseContext !== undefined) {
|
|
496
509
|
await setClaudeParseContext(tx, {
|
|
@@ -520,6 +533,8 @@ export async function appendLogs(
|
|
|
520
533
|
});
|
|
521
534
|
|
|
522
535
|
if (metrics.streamOpened) streamOpenedCount.add(1);
|
|
536
|
+
if (metrics.ingestedBytes > 0) bytesIngestedCount.add(metrics.ingestedBytes);
|
|
537
|
+
if (metrics.storedBytes > 0) bytesStoredCount.add(metrics.storedBytes);
|
|
523
538
|
for (const [kind, count] of Object.entries(metrics.recordCounts)) {
|
|
524
539
|
if (count > 0) recordAppendedCount.add(count, {kind: kind as LogRecordMetricKind});
|
|
525
540
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {appendLogs} from '#core/append-logs.js';
|
|
2
|
+
import {
|
|
3
|
+
endLine,
|
|
4
|
+
groupStartLine,
|
|
5
|
+
ndjsonBody,
|
|
6
|
+
outputLine,
|
|
7
|
+
outputOfBytes,
|
|
8
|
+
} from '#test/fixtures/ndjson.js';
|
|
9
|
+
import {findStream, listChunks} from '#test/queries.js';
|
|
10
|
+
import {getUncompactedChunkBytes} from './chunks.js';
|
|
11
|
+
|
|
12
|
+
interface Ctx {
|
|
13
|
+
jobId: string;
|
|
14
|
+
stepId: string;
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
projectId: string;
|
|
17
|
+
workflowRunAttemptId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function newCtx(): Ctx {
|
|
21
|
+
return {
|
|
22
|
+
jobId: crypto.randomUUID(),
|
|
23
|
+
stepId: crypto.randomUUID(),
|
|
24
|
+
workspaceId: crypto.randomUUID(),
|
|
25
|
+
projectId: crypto.randomUUID(),
|
|
26
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function chunkBytesOf(streamId: string): Promise<number> {
|
|
31
|
+
return listChunks(streamId).then((chunks) =>
|
|
32
|
+
chunks.reduce((total, chunk) => total + chunk.byteLen, 0),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('getUncompactedChunkBytes', () => {
|
|
37
|
+
it('sums chunk bytes across open and declared-closed streams, control chunks included', async () => {
|
|
38
|
+
const before = await getUncompactedChunkBytes();
|
|
39
|
+
const open = newCtx();
|
|
40
|
+
const closed = newCtx();
|
|
41
|
+
// 150 payload bytes cross the 100-byte test budget, so this stream also gets a `capped`
|
|
42
|
+
// control chunk: the gauge must count it, since compaction will move it to storage too.
|
|
43
|
+
const crossingBody = outputOfBytes(150);
|
|
44
|
+
await appendLogs({...open, attempt: 1, offset: 0, body: crossingBody});
|
|
45
|
+
const closedBody = ndjsonBody(outputLine('done\n'), groupStartLine('g1', 'Build'), endLine(4));
|
|
46
|
+
await appendLogs({...closed, attempt: 1, offset: 0, body: closedBody});
|
|
47
|
+
|
|
48
|
+
const openStream = await findStream({...open, attempt: 1});
|
|
49
|
+
const closedStream = await findStream({...closed, attempt: 1});
|
|
50
|
+
const expected =
|
|
51
|
+
(await chunkBytesOf(openStream?.id as string)) +
|
|
52
|
+
(await chunkBytesOf(closedStream?.id as string));
|
|
53
|
+
|
|
54
|
+
const after = await getUncompactedChunkBytes();
|
|
55
|
+
|
|
56
|
+
expect(after - before).toBe(BigInt(expected));
|
|
57
|
+
expect(closedStream?.state).toBe('closed');
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/db/chunks.ts
CHANGED
|
@@ -114,6 +114,20 @@ export async function readChunkPageBySeq(params: {
|
|
|
114
114
|
return {data: Buffer.concat(rows.map((row) => row.data)), nextSeq, hasMore};
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Total bytes held in hot chunk rows across all streams, runner and server-injected
|
|
119
|
+
* control chunks alike. Chunks are only deleted by compaction, so this is exactly the
|
|
120
|
+
* un-compacted hot volume the service gauge reports (open streams plus closed streams
|
|
121
|
+
* still awaiting compaction).
|
|
122
|
+
*/
|
|
123
|
+
export async function getUncompactedChunkBytes(): Promise<bigint> {
|
|
124
|
+
const [row] = await db()
|
|
125
|
+
.select({value: sql<bigint>`coalesce(sum(${logChunks.byteLen}), 0)`.mapWith(BigInt)})
|
|
126
|
+
.from(logChunks);
|
|
127
|
+
|
|
128
|
+
return row?.value ?? 0n;
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
export interface ChunkStats {
|
|
118
132
|
count: number;
|
|
119
133
|
maxSeq: number;
|
package/src/metrics/instance.ts
CHANGED
|
@@ -14,6 +14,38 @@ export const streamOpenedCount = meter.createCounter<Record<string, never>>('log
|
|
|
14
14
|
description: 'Log streams opened by first append',
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
// Byte-volume counters use the OpenTelemetry byte unit so Prometheus appends `_bytes` to the
|
|
18
|
+
// metric name. The two ingest axes MUST stay distinct:
|
|
19
|
+
//
|
|
20
|
+
// raw ingested (CAS axis) normalized stored (read axis)
|
|
21
|
+
// runner body bytes accepted durable chunk bytes written after ingest
|
|
22
|
+
// by the offset-CAS, before normalization (agent_session records are parsed
|
|
23
|
+
// normalization into view rows), excluding server tombstones
|
|
24
|
+
//
|
|
25
|
+
// `logs_bytes_ingested` counts what the protocol accepted: every in-order CAS extension,
|
|
26
|
+
// including cap-crossing appends and post-cap accept-and-drop stragglers (their
|
|
27
|
+
// `committed_length` advances, so they are accepted even though nothing is stored).
|
|
28
|
+
// Retries, gaps, closed-stream appends, and empty heartbeats never extend the CAS and are
|
|
29
|
+
// never counted, so the same bytes are never double-counted.
|
|
30
|
+
//
|
|
31
|
+
// `logs_bytes_stored` counts only normalized bodies durably written as chunk rows, so a
|
|
32
|
+
// capped job's dropped straggler and server-injected `capped`/`runner_lost` tombstones do
|
|
33
|
+
// not inflate it. ingested - stored is the normalization delta plus cap/close drops.
|
|
34
|
+
export const bytesIngestedCount = meter.createCounter<Record<string, never>>(
|
|
35
|
+
'logs_bytes_ingested',
|
|
36
|
+
{
|
|
37
|
+
description:
|
|
38
|
+
'Raw runner bytes accepted after offset validation (in-order CAS extension; retries, gaps, closed-stream and cap-dropped bodies excluded)',
|
|
39
|
+
unit: 'By',
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export const bytesStoredCount = meter.createCounter<Record<string, never>>('logs_bytes_stored', {
|
|
44
|
+
description:
|
|
45
|
+
'Normalized durable bytes written to log chunks from runner appends (server tombstones and cap-dropped bodies excluded)',
|
|
46
|
+
unit: 'By',
|
|
47
|
+
});
|
|
48
|
+
|
|
17
49
|
export const streamClosedCount = meter.createCounter<{reason: 'declared' | 'timeout'}>(
|
|
18
50
|
'logs_stream_closed',
|
|
19
51
|
{description: 'Log streams closed by reason'},
|
|
@@ -31,3 +63,15 @@ export const compactionCount = meter.createCounter<{outcome: CompactionMetricOut
|
|
|
31
63
|
'logs_compaction',
|
|
32
64
|
{description: 'Log stream compaction attempts by outcome'},
|
|
33
65
|
);
|
|
66
|
+
|
|
67
|
+
// Uncompressed NDJSON log bytes (not the gzip object size), matching the byte axis of the
|
|
68
|
+
// ingest/storage counters and the `uncompressed_bytes` object metadata. Recorded only once
|
|
69
|
+
// per stream on the single-winner publish, so an idempotent re-run (`already-compacted`) or
|
|
70
|
+
// a failed attempt never double-counts.
|
|
71
|
+
export const compactedBytesCount = meter.createCounter<Record<string, never>>(
|
|
72
|
+
'logs_compacted_bytes',
|
|
73
|
+
{
|
|
74
|
+
description: 'Uncompressed log bytes successfully compacted to object storage',
|
|
75
|
+
unit: 'By',
|
|
76
|
+
},
|
|
77
|
+
);
|
package/src/metrics/service.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {getServiceMetricsProvider} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import {getUncompactedChunkBytes} from '#db/chunks.js';
|
|
2
3
|
import {getOpenStreamCount} from '#db/streams.js';
|
|
3
4
|
|
|
4
5
|
export function registerLogsServiceMetrics(): void {
|
|
@@ -8,11 +9,20 @@ export function registerLogsServiceMetrics(): void {
|
|
|
8
9
|
description: 'Log streams currently open for appends',
|
|
9
10
|
});
|
|
10
11
|
|
|
12
|
+
// Hot-storage volume on the service plane: the chunk rows live in shared Postgres, so every
|
|
13
|
+
// pod would report the same sum and Prometheus must not add them together.
|
|
14
|
+
const openChunkBytes = meter.createObservableGauge('logs_open_chunk_bytes', {
|
|
15
|
+
description:
|
|
16
|
+
'Bytes in un-compacted hot log chunks (open streams plus closed streams awaiting compaction)',
|
|
17
|
+
unit: 'By',
|
|
18
|
+
});
|
|
19
|
+
|
|
11
20
|
meter.addBatchObservableCallback(
|
|
12
21
|
async (observer) => {
|
|
13
22
|
observer.observe(openStreams, toSafeGaugeNumber(await getOpenStreamCount()));
|
|
23
|
+
observer.observe(openChunkBytes, toSafeGaugeNumber(await getUncompactedChunkBytes()));
|
|
14
24
|
},
|
|
15
|
-
[openStreams],
|
|
25
|
+
[openStreams, openChunkBytes],
|
|
16
26
|
);
|
|
17
27
|
}
|
|
18
28
|
|
|
@@ -6,7 +6,11 @@ import {compactedGzipStream} from '#core/compaction.js';
|
|
|
6
6
|
import {chunkStats} from '#db/chunks.js';
|
|
7
7
|
import {db, type Transaction} from '#db/db.js';
|
|
8
8
|
import {getAttemptStreamById, setObjectKeyAndDeleteChunks} from '#db/streams.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type CompactionMetricOutcome,
|
|
11
|
+
compactedBytesCount,
|
|
12
|
+
compactionCount,
|
|
13
|
+
} from '#metrics/instance.js';
|
|
10
14
|
|
|
11
15
|
export type CompactStreamResult =
|
|
12
16
|
| {outcome: 'gone'}
|
|
@@ -125,6 +129,12 @@ export function createCompactStreamActivity(
|
|
|
125
129
|
try {
|
|
126
130
|
const result = await compactStream(params, dependencies);
|
|
127
131
|
outcome = result.outcome;
|
|
132
|
+
// Count uncompressed log bytes only on the single-winner publish: idempotent re-runs
|
|
133
|
+
// (`already-compacted`) and failed attempts never reach this branch, so the counter
|
|
134
|
+
// tracks exactly the bytes durably moved to object storage.
|
|
135
|
+
if (result.outcome === 'compacted') {
|
|
136
|
+
compactedBytesCount.add(result.uncompressedBytes);
|
|
137
|
+
}
|
|
128
138
|
return result;
|
|
129
139
|
} finally {
|
|
130
140
|
compactionCount.add(1, {outcome});
|