@shipfox/api-logs 12.7.0 → 13.1.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-logs",
3
3
  "license": "MIT",
4
- "version": "12.7.0",
4
+ "version": "13.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,7 +27,7 @@
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": "12.7.0",
30
+ "@shipfox/api-workflows-dto": "13.1.0",
31
31
  "@shipfox/workflow-document": "3.0.1",
32
32
  "@shipfox/config": "1.2.4",
33
33
  "@shipfox/node-drizzle": "0.3.5",
@@ -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',
@@ -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;
@@ -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
+ );
@@ -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 {type CompactionMetricOutcome, compactionCount} from '#metrics/instance.js';
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});