@redthreadlabs/tracelog-schema 0.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/README.md +33 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +30 -0
- package/dist/keys.d.ts +66 -0
- package/dist/keys.js +108 -0
- package/dist/kinds.d.ts +9 -0
- package/dist/kinds.js +16 -0
- package/dist/sidecar.d.ts +62 -0
- package/dist/sidecar.js +134 -0
- package/dist/wire.d.ts +96 -0
- package/dist/wire.js +6 -0
- package/package.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @redthreadlabs/tracelog-schema
|
|
2
|
+
|
|
3
|
+
The shared **contract** for the tracelog suite — one dependency-free, isomorphic
|
|
4
|
+
source of truth so the agent (writer), the client SDK, the server, and the
|
|
5
|
+
viewer (reader) never drift.
|
|
6
|
+
|
|
7
|
+
It contains only the contract: types and pure functions, no I/O.
|
|
8
|
+
|
|
9
|
+
- **Record kinds** (`kinds.ts`) — the top-level kinds of each NDJSON line
|
|
10
|
+
(`transaction`, `span`, `error`, `event`, `metricset`).
|
|
11
|
+
- **S3 key layout** (`keys.ts`) — `buildKey` / `parseKey` (exact inverses) plus
|
|
12
|
+
`intervalSpan`, `overlapsRange`, `dedupeCurrents`, `normalizeHost`. The layout
|
|
13
|
+
`{channel}/{interval}/{host}[_{seq}][_current].jsonl[.gz]` is fixed.
|
|
14
|
+
- **Metadata sidecar** (`sidecar.ts`) — the `SidecarMeta` shape written at
|
|
15
|
+
`<logkey>.meta.json`, plus the `MetaAccumulator` that derives it from a file's
|
|
16
|
+
bytes (uncompressed size, record count, and an hourly interval×kind histogram).
|
|
17
|
+
- **Wire format** (`wire.ts`) — the `POST /logs` ingest types (`LogBatch`,
|
|
18
|
+
`LogEventItem`, `TimerItem`, `ClientInfo`).
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { parseKey, MetaAccumulator, RECORD_KINDS, type LogEventItem } from '@redthreadlabs/tracelog-schema';
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Build / test
|
|
27
|
+
|
|
28
|
+
Plain `tsc` to CommonJS `dist/`; tests run against the compiled output.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
npm run build
|
|
32
|
+
npm test
|
|
33
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @redthreadlabs/tracelog-schema — the shared contract for the tracelog suite:
|
|
3
|
+
* record kinds, the S3 key layout, the metadata sidecar, and the `/logs` wire
|
|
4
|
+
* format. One dependency-free, isomorphic source of truth, so the agent
|
|
5
|
+
* (writer), the client SDK, the server, and the viewer (reader) never drift.
|
|
6
|
+
*/
|
|
7
|
+
export * from './kinds';
|
|
8
|
+
export * from './keys';
|
|
9
|
+
export * from './sidecar';
|
|
10
|
+
export * from './wire';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Copyright Red Thread Labs LLC. All rights reserved.
|
|
4
|
+
* Licensed under the BSD 2-Clause License.
|
|
5
|
+
*/
|
|
6
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
7
|
+
if (k2 === undefined) k2 = k;
|
|
8
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
9
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
10
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
11
|
+
}
|
|
12
|
+
Object.defineProperty(o, k2, desc);
|
|
13
|
+
}) : (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
o[k2] = m[k];
|
|
16
|
+
}));
|
|
17
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
18
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
19
|
+
};
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
/**
|
|
22
|
+
* @redthreadlabs/tracelog-schema — the shared contract for the tracelog suite:
|
|
23
|
+
* record kinds, the S3 key layout, the metadata sidecar, and the `/logs` wire
|
|
24
|
+
* format. One dependency-free, isomorphic source of truth, so the agent
|
|
25
|
+
* (writer), the client SDK, the server, and the viewer (reader) never drift.
|
|
26
|
+
*/
|
|
27
|
+
__exportStar(require("./kinds"), exports);
|
|
28
|
+
__exportStar(require("./keys"), exports);
|
|
29
|
+
__exportStar(require("./sidecar"), exports);
|
|
30
|
+
__exportStar(require("./wire"), exports);
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The S3 key layout — the contract between the writer (the tracelog agent)
|
|
3
|
+
* and any reader (the viewer). It is FIXED, not configurable:
|
|
4
|
+
*
|
|
5
|
+
* {channel}/{interval}/{host}[_{seq}][_current].jsonl[.gz]
|
|
6
|
+
*
|
|
7
|
+
* server/2026-06-11/172.31.27.225.jsonl.gz
|
|
8
|
+
* server/2026-06-11/172.31.27.225_current.jsonl.gz
|
|
9
|
+
* server/2026-06-11/172.31.27.225_1.jsonl.gz
|
|
10
|
+
*
|
|
11
|
+
* Channel comes before interval so prefix-scoped lifecycle rules work and a
|
|
12
|
+
* date-range scan within a channel is one lexicographically-ordered listing.
|
|
13
|
+
* The basename is underscore-delimited (hostnames cannot contain underscores):
|
|
14
|
+
* host, then a numeric size-rotation seq when > 0, then the literal 'current'
|
|
15
|
+
* for the live snapshot. `buildKey` and `parseKey` are exact inverses so the
|
|
16
|
+
* agent and viewer never drift on this layout.
|
|
17
|
+
*/
|
|
18
|
+
export interface KeyVars {
|
|
19
|
+
channel: string;
|
|
20
|
+
interval: string;
|
|
21
|
+
host: string;
|
|
22
|
+
/** size-rotation sequence within the interval; 0 (or omitted) = first file */
|
|
23
|
+
seq?: number;
|
|
24
|
+
/** the live, still-being-written snapshot */
|
|
25
|
+
current?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface ParsedKey {
|
|
28
|
+
key: string;
|
|
29
|
+
channel: string;
|
|
30
|
+
interval: string;
|
|
31
|
+
host: string;
|
|
32
|
+
seq: number;
|
|
33
|
+
current: boolean;
|
|
34
|
+
/** compressed object size in bytes, from the listing (0 if unknown) */
|
|
35
|
+
size: number;
|
|
36
|
+
lastModified?: Date;
|
|
37
|
+
etag?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Build a log object's key. Pass `gzip` to append the `.gz` suffix. */
|
|
40
|
+
export declare function buildKey(vars: KeyVars, gzip?: boolean): string;
|
|
41
|
+
/**
|
|
42
|
+
* Parse a log object's key back into its parts, or null if it is not a log
|
|
43
|
+
* file in the known grammar (e.g. a sidecar `.meta.json`, or anything else).
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseKey(key: string, size?: number, lastModified?: Date, etag?: string): ParsedKey | null;
|
|
46
|
+
/**
|
|
47
|
+
* The UTC time span an interval label covers: daily `YYYY-MM-DD` → 24 h,
|
|
48
|
+
* hourly `YYYY-MM-DDTHH` → 1 h. Unknown grammar → null (callers should be
|
|
49
|
+
* conservative and keep the file).
|
|
50
|
+
*/
|
|
51
|
+
export declare function intervalSpan(interval: string): [number, number] | null;
|
|
52
|
+
/** Whether a file's interval overlaps [startMs, endMs]. Unknown layout → kept. */
|
|
53
|
+
export declare function overlapsRange(file: Pick<ParsedKey, 'interval'>, startMs: number, endMs: number): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Drop `_current` snapshots shadowed by their finalized file: if the finalized
|
|
56
|
+
* key exists, ignore the (briefly surviving) `_current`. A `_current` with no
|
|
57
|
+
* finalized sibling is kept — it is either live (today) or a dead host's only
|
|
58
|
+
* copy.
|
|
59
|
+
*/
|
|
60
|
+
export declare function dedupeCurrents<T extends Pick<ParsedKey, 'channel' | 'interval' | 'host' | 'seq' | 'current'>>(files: T[]): T[];
|
|
61
|
+
/**
|
|
62
|
+
* Normalize a hostname into the host label used in keys. EC2 internal
|
|
63
|
+
* hostnames (`ip-A-B-C-D[.…]`) become the dotted IP — which avoids embedding
|
|
64
|
+
* hyphens in the basename; any other hostname is used as-is.
|
|
65
|
+
*/
|
|
66
|
+
export declare function normalizeHost(hostname: string): string;
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Copyright Red Thread Labs LLC. All rights reserved.
|
|
4
|
+
* Licensed under the BSD 2-Clause License.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.buildKey = buildKey;
|
|
8
|
+
exports.parseKey = parseKey;
|
|
9
|
+
exports.intervalSpan = intervalSpan;
|
|
10
|
+
exports.overlapsRange = overlapsRange;
|
|
11
|
+
exports.dedupeCurrents = dedupeCurrents;
|
|
12
|
+
exports.normalizeHost = normalizeHost;
|
|
13
|
+
/** Build a log object's key. Pass `gzip` to append the `.gz` suffix. */
|
|
14
|
+
function buildKey(vars, gzip = false) {
|
|
15
|
+
let basename = vars.host;
|
|
16
|
+
if (vars.seq && vars.seq > 0)
|
|
17
|
+
basename += `_${vars.seq}`;
|
|
18
|
+
if (vars.current)
|
|
19
|
+
basename += '_current';
|
|
20
|
+
return `${vars.channel}/${vars.interval}/${basename}.jsonl${gzip ? '.gz' : ''}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Parse a log object's key back into its parts, or null if it is not a log
|
|
24
|
+
* file in the known grammar (e.g. a sidecar `.meta.json`, or anything else).
|
|
25
|
+
*/
|
|
26
|
+
function parseKey(key, size = 0, lastModified, etag) {
|
|
27
|
+
const parts = key.split('/');
|
|
28
|
+
if (parts.length !== 3)
|
|
29
|
+
return null;
|
|
30
|
+
const [channel, interval, file] = parts;
|
|
31
|
+
if (!channel || !interval || !file)
|
|
32
|
+
return null;
|
|
33
|
+
let base = file;
|
|
34
|
+
if (base.endsWith('.gz'))
|
|
35
|
+
base = base.slice(0, -3);
|
|
36
|
+
if (!base.endsWith('.jsonl'))
|
|
37
|
+
return null;
|
|
38
|
+
base = base.slice(0, -'.jsonl'.length);
|
|
39
|
+
const segments = base.split('_');
|
|
40
|
+
const host = segments[0];
|
|
41
|
+
if (!host)
|
|
42
|
+
return null;
|
|
43
|
+
let seq = 0;
|
|
44
|
+
let current = false;
|
|
45
|
+
let rest = segments.slice(1);
|
|
46
|
+
if (rest[rest.length - 1] === 'current') {
|
|
47
|
+
current = true;
|
|
48
|
+
rest = rest.slice(0, -1);
|
|
49
|
+
}
|
|
50
|
+
if (rest.length === 1 && /^\d+$/.test(rest[0])) {
|
|
51
|
+
seq = parseInt(rest[0], 10);
|
|
52
|
+
}
|
|
53
|
+
else if (rest.length > 0) {
|
|
54
|
+
return null; // not the grammar we know — ignore, don't fail
|
|
55
|
+
}
|
|
56
|
+
return { key, channel, interval, host, seq, current, size, lastModified, etag };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The UTC time span an interval label covers: daily `YYYY-MM-DD` → 24 h,
|
|
60
|
+
* hourly `YYYY-MM-DDTHH` → 1 h. Unknown grammar → null (callers should be
|
|
61
|
+
* conservative and keep the file).
|
|
62
|
+
*/
|
|
63
|
+
function intervalSpan(interval) {
|
|
64
|
+
let m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(interval);
|
|
65
|
+
if (m) {
|
|
66
|
+
const t0 = Date.UTC(+m[1], +m[2] - 1, +m[3]);
|
|
67
|
+
return [t0, t0 + 86400000];
|
|
68
|
+
}
|
|
69
|
+
m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})$/.exec(interval);
|
|
70
|
+
if (m) {
|
|
71
|
+
const t0 = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4]);
|
|
72
|
+
return [t0, t0 + 3600000];
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/** Whether a file's interval overlaps [startMs, endMs]. Unknown layout → kept. */
|
|
77
|
+
function overlapsRange(file, startMs, endMs) {
|
|
78
|
+
const span = intervalSpan(file.interval);
|
|
79
|
+
if (!span)
|
|
80
|
+
return true;
|
|
81
|
+
return span[0] <= endMs && span[1] > startMs;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Drop `_current` snapshots shadowed by their finalized file: if the finalized
|
|
85
|
+
* key exists, ignore the (briefly surviving) `_current`. A `_current` with no
|
|
86
|
+
* finalized sibling is kept — it is either live (today) or a dead host's only
|
|
87
|
+
* copy.
|
|
88
|
+
*/
|
|
89
|
+
function dedupeCurrents(files) {
|
|
90
|
+
const finalized = new Set();
|
|
91
|
+
for (const f of files) {
|
|
92
|
+
if (!f.current)
|
|
93
|
+
finalized.add(`${f.channel}/${f.interval}/${f.host}/${f.seq}`);
|
|
94
|
+
}
|
|
95
|
+
return files.filter((f) => !f.current || !finalized.has(`${f.channel}/${f.interval}/${f.host}/${f.seq}`));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Normalize a hostname into the host label used in keys. EC2 internal
|
|
99
|
+
* hostnames (`ip-A-B-C-D[.…]`) become the dotted IP — which avoids embedding
|
|
100
|
+
* hyphens in the basename; any other hostname is used as-is.
|
|
101
|
+
*/
|
|
102
|
+
function normalizeHost(hostname) {
|
|
103
|
+
const m = /^ip-(\d{1,3})-(\d{1,3})-(\d{1,3})-(\d{1,3})(\..*)?$/.exec(hostname);
|
|
104
|
+
if (m) {
|
|
105
|
+
return `${m[1]}.${m[2]}.${m[3]}.${m[4]}`;
|
|
106
|
+
}
|
|
107
|
+
return hostname;
|
|
108
|
+
}
|
package/dist/kinds.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record kinds that appear as the single top-level key of every NDJSON
|
|
3
|
+
* line tracelog writes: `{ "<kind>": { ...fields... } }`. `metadata` is the
|
|
4
|
+
* once-per-file header line, not a data record, so it is not a RecordKind.
|
|
5
|
+
*/
|
|
6
|
+
export type RecordKind = 'transaction' | 'span' | 'error' | 'event' | 'metricset';
|
|
7
|
+
export declare const RECORD_KINDS: readonly RecordKind[];
|
|
8
|
+
/** The header line's kind. Every file's first line is `{ "metadata": {...} }`. */
|
|
9
|
+
export declare const METADATA_KIND = "metadata";
|
package/dist/kinds.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Copyright Red Thread Labs LLC. All rights reserved.
|
|
4
|
+
* Licensed under the BSD 2-Clause License.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.METADATA_KIND = exports.RECORD_KINDS = void 0;
|
|
8
|
+
exports.RECORD_KINDS = [
|
|
9
|
+
'transaction',
|
|
10
|
+
'span',
|
|
11
|
+
'error',
|
|
12
|
+
'event',
|
|
13
|
+
'metricset',
|
|
14
|
+
];
|
|
15
|
+
/** The header line's kind. Every file's first line is `{ "metadata": {...} }`. */
|
|
16
|
+
exports.METADATA_KIND = 'metadata';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The metadata sidecar: a tiny JSON object written alongside each log object
|
|
3
|
+
* at `<logkey>.meta.json`, carrying the facts the gzipped body hides so a
|
|
4
|
+
* reader needs no estimation. `bytes` is the uncompressed size; `intervals`
|
|
5
|
+
* is an hourly UTC histogram of records by kind. The histogram matters
|
|
6
|
+
* because buffered remote clients can land past-dated records in today's
|
|
7
|
+
* file, so a file's nominal interval is a filing label, not a description of
|
|
8
|
+
* its contents.
|
|
9
|
+
*
|
|
10
|
+
* records === malformed + Σ(all interval/kind counts)
|
|
11
|
+
*/
|
|
12
|
+
export declare const SIDECAR_VERSION = 1;
|
|
13
|
+
export declare const SIDECAR_SUFFIX = ".meta.json";
|
|
14
|
+
export interface SidecarMeta {
|
|
15
|
+
v: number;
|
|
16
|
+
/** the file's default (nominal) interval, from its key */
|
|
17
|
+
interval: string;
|
|
18
|
+
/** uncompressed byte size */
|
|
19
|
+
bytes: number;
|
|
20
|
+
/** compressed (gzipped) object size */
|
|
21
|
+
compressed: number;
|
|
22
|
+
/** total records (metadata header line excluded) */
|
|
23
|
+
records: number;
|
|
24
|
+
/** records whose timestamp was missing/garbage (not placed in an interval) */
|
|
25
|
+
malformed: number;
|
|
26
|
+
/** hourly UTC histogram: { 'YYYY-MM-DDTHH': { kind: count } } */
|
|
27
|
+
intervals: Record<string, Record<string, number>>;
|
|
28
|
+
}
|
|
29
|
+
/** The sidecar key for a log object key. */
|
|
30
|
+
export declare function sidecarKey(logKey: string): string;
|
|
31
|
+
/** epoch-ms → UTC hour-bucket label 'YYYY-MM-DDTHH'. */
|
|
32
|
+
export declare function hourBucket(ms: number): string;
|
|
33
|
+
/**
|
|
34
|
+
* Derives a log file's sidecar histogram by parsing its NDJSON lines. The file
|
|
35
|
+
* on disk is the source of truth: counts come from the exact bytes uploaded,
|
|
36
|
+
* so they cannot drift from the object, and a restart (which wipes any
|
|
37
|
+
* write-time counters) or an orphaned file is handled for free by re-deriving.
|
|
38
|
+
*
|
|
39
|
+
* Tolerant by design: an unparseable line is skipped (not a record); a record
|
|
40
|
+
* with a missing/garbage timestamp is counted as `malformed` rather than
|
|
41
|
+
* forced into an interval. Append-only safe: addChunk may be fed successive
|
|
42
|
+
* tails of a growing current file, since every line is newline-terminated so
|
|
43
|
+
* chunk/offset boundaries land between lines.
|
|
44
|
+
*/
|
|
45
|
+
export declare class MetaAccumulator {
|
|
46
|
+
/** bytes consumed so far (for incremental current-file parsing) */
|
|
47
|
+
offset: number;
|
|
48
|
+
records: number;
|
|
49
|
+
malformed: number;
|
|
50
|
+
intervals: Record<string, Record<string, number>>;
|
|
51
|
+
private partial;
|
|
52
|
+
addChunk(text: string): void;
|
|
53
|
+
flushPartial(): void;
|
|
54
|
+
private addLine;
|
|
55
|
+
/**
|
|
56
|
+
* The sidecar object for this file. Keys are emitted in a fixed, sorted
|
|
57
|
+
* order at every level so identical contents serialize to byte-identical
|
|
58
|
+
* JSON regardless of record arrival order — making a sidecar's ETag a
|
|
59
|
+
* reliable sameness check.
|
|
60
|
+
*/
|
|
61
|
+
toMeta(interval: string, bytes: number, compressed: number): SidecarMeta;
|
|
62
|
+
}
|
package/dist/sidecar.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Copyright Red Thread Labs LLC. All rights reserved.
|
|
4
|
+
* Licensed under the BSD 2-Clause License.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.MetaAccumulator = exports.SIDECAR_SUFFIX = exports.SIDECAR_VERSION = void 0;
|
|
8
|
+
exports.sidecarKey = sidecarKey;
|
|
9
|
+
exports.hourBucket = hourBucket;
|
|
10
|
+
const kinds_1 = require("./kinds");
|
|
11
|
+
/**
|
|
12
|
+
* The metadata sidecar: a tiny JSON object written alongside each log object
|
|
13
|
+
* at `<logkey>.meta.json`, carrying the facts the gzipped body hides so a
|
|
14
|
+
* reader needs no estimation. `bytes` is the uncompressed size; `intervals`
|
|
15
|
+
* is an hourly UTC histogram of records by kind. The histogram matters
|
|
16
|
+
* because buffered remote clients can land past-dated records in today's
|
|
17
|
+
* file, so a file's nominal interval is a filing label, not a description of
|
|
18
|
+
* its contents.
|
|
19
|
+
*
|
|
20
|
+
* records === malformed + Σ(all interval/kind counts)
|
|
21
|
+
*/
|
|
22
|
+
exports.SIDECAR_VERSION = 1;
|
|
23
|
+
exports.SIDECAR_SUFFIX = '.meta.json';
|
|
24
|
+
/** The sidecar key for a log object key. */
|
|
25
|
+
function sidecarKey(logKey) {
|
|
26
|
+
return logKey + exports.SIDECAR_SUFFIX;
|
|
27
|
+
}
|
|
28
|
+
function pad2(n) {
|
|
29
|
+
return String(n).padStart(2, '0');
|
|
30
|
+
}
|
|
31
|
+
/** epoch-ms → UTC hour-bucket label 'YYYY-MM-DDTHH'. */
|
|
32
|
+
function hourBucket(ms) {
|
|
33
|
+
const d = new Date(ms);
|
|
34
|
+
return (`${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}` +
|
|
35
|
+
`T${pad2(d.getUTCHours())}`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Derives a log file's sidecar histogram by parsing its NDJSON lines. The file
|
|
39
|
+
* on disk is the source of truth: counts come from the exact bytes uploaded,
|
|
40
|
+
* so they cannot drift from the object, and a restart (which wipes any
|
|
41
|
+
* write-time counters) or an orphaned file is handled for free by re-deriving.
|
|
42
|
+
*
|
|
43
|
+
* Tolerant by design: an unparseable line is skipped (not a record); a record
|
|
44
|
+
* with a missing/garbage timestamp is counted as `malformed` rather than
|
|
45
|
+
* forced into an interval. Append-only safe: addChunk may be fed successive
|
|
46
|
+
* tails of a growing current file, since every line is newline-terminated so
|
|
47
|
+
* chunk/offset boundaries land between lines.
|
|
48
|
+
*/
|
|
49
|
+
class MetaAccumulator {
|
|
50
|
+
constructor() {
|
|
51
|
+
/** bytes consumed so far (for incremental current-file parsing) */
|
|
52
|
+
this.offset = 0;
|
|
53
|
+
this.records = 0;
|
|
54
|
+
this.malformed = 0;
|
|
55
|
+
this.intervals = Object.create(null);
|
|
56
|
+
this.partial = '';
|
|
57
|
+
}
|
|
58
|
+
addChunk(text) {
|
|
59
|
+
if (!text)
|
|
60
|
+
return;
|
|
61
|
+
const s = this.partial + text;
|
|
62
|
+
let start = 0;
|
|
63
|
+
let nl;
|
|
64
|
+
while ((nl = s.indexOf('\n', start)) !== -1) {
|
|
65
|
+
this.addLine(s.slice(start, nl));
|
|
66
|
+
start = nl + 1;
|
|
67
|
+
}
|
|
68
|
+
this.partial = s.slice(start);
|
|
69
|
+
}
|
|
70
|
+
flushPartial() {
|
|
71
|
+
if (this.partial) {
|
|
72
|
+
this.addLine(this.partial);
|
|
73
|
+
this.partial = '';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
addLine(line) {
|
|
77
|
+
const t = line.trim();
|
|
78
|
+
if (!t)
|
|
79
|
+
return;
|
|
80
|
+
let obj;
|
|
81
|
+
try {
|
|
82
|
+
obj = JSON.parse(t);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return; // corrupt line — not a countable record
|
|
86
|
+
}
|
|
87
|
+
if (!obj || typeof obj !== 'object')
|
|
88
|
+
return;
|
|
89
|
+
const kind = Object.keys(obj)[0];
|
|
90
|
+
if (!kind || kind === kinds_1.METADATA_KIND)
|
|
91
|
+
return;
|
|
92
|
+
this.records++;
|
|
93
|
+
const body = obj[kind];
|
|
94
|
+
const tsUs = body &&
|
|
95
|
+
typeof body.timestamp === 'number' &&
|
|
96
|
+
isFinite(body.timestamp) &&
|
|
97
|
+
body.timestamp > 0
|
|
98
|
+
? body.timestamp
|
|
99
|
+
: 0;
|
|
100
|
+
if (!tsUs) {
|
|
101
|
+
this.malformed++;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const bucket = hourBucket(tsUs / 1000); // serialized timestamps are epoch-µs
|
|
105
|
+
const byKind = this.intervals[bucket] || (this.intervals[bucket] = Object.create(null));
|
|
106
|
+
byKind[kind] = (byKind[kind] || 0) + 1;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The sidecar object for this file. Keys are emitted in a fixed, sorted
|
|
110
|
+
* order at every level so identical contents serialize to byte-identical
|
|
111
|
+
* JSON regardless of record arrival order — making a sidecar's ETag a
|
|
112
|
+
* reliable sameness check.
|
|
113
|
+
*/
|
|
114
|
+
toMeta(interval, bytes, compressed) {
|
|
115
|
+
const intervals = Object.create(null);
|
|
116
|
+
for (const hour of Object.keys(this.intervals).sort()) {
|
|
117
|
+
const src = this.intervals[hour];
|
|
118
|
+
const sorted = Object.create(null);
|
|
119
|
+
for (const kind of Object.keys(src).sort())
|
|
120
|
+
sorted[kind] = src[kind];
|
|
121
|
+
intervals[hour] = sorted;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
v: exports.SIDECAR_VERSION,
|
|
125
|
+
interval,
|
|
126
|
+
bytes,
|
|
127
|
+
compressed,
|
|
128
|
+
records: this.records,
|
|
129
|
+
malformed: this.malformed,
|
|
130
|
+
intervals,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
exports.MetaAccumulator = MetaAccumulator;
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire format for the `POST /logs` ingest endpoint: what a remote client
|
|
3
|
+
* (browser, React Native) sends and what the server parses. The server maps
|
|
4
|
+
* these into the on-disk record kinds (see kinds.ts). Defined here so the
|
|
5
|
+
* client SDK, the server, and the viewer all share one definition.
|
|
6
|
+
*/
|
|
7
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
8
|
+
[key: string]: JsonValue;
|
|
9
|
+
};
|
|
10
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
11
|
+
export interface LogBatch {
|
|
12
|
+
client: ClientInfo;
|
|
13
|
+
user_id?: string;
|
|
14
|
+
session_ref?: string;
|
|
15
|
+
device_id?: string;
|
|
16
|
+
events: LogEventItem[];
|
|
17
|
+
timers: TimerItem[];
|
|
18
|
+
}
|
|
19
|
+
export interface LogEventItem {
|
|
20
|
+
/** Event category, e.g. 'auth', 'billing', 'startup'. Default: 'client-log' */
|
|
21
|
+
type: string;
|
|
22
|
+
/** Epoch milliseconds */
|
|
23
|
+
timestamp: number;
|
|
24
|
+
level: LogLevel;
|
|
25
|
+
message: string;
|
|
26
|
+
/** Duration in milliseconds (for timed events that aren't span-shaped) */
|
|
27
|
+
duration?: number;
|
|
28
|
+
/** Serialized error info. `code` is the structured error code. */
|
|
29
|
+
error?: {
|
|
30
|
+
message: string;
|
|
31
|
+
type?: string;
|
|
32
|
+
code?: string;
|
|
33
|
+
stack?: string;
|
|
34
|
+
};
|
|
35
|
+
/** Arbitrary key-value event data */
|
|
36
|
+
params?: Record<string, JsonValue>;
|
|
37
|
+
/**
|
|
38
|
+
* Minutes east of UTC at the moment the event was recorded (ISO-8601 sign:
|
|
39
|
+
* `localWallClock = UTC + tz_offset`). Captured per-event because buffered
|
|
40
|
+
* clients can record across DST or travel and flush later. e.g. EST = -300,
|
|
41
|
+
* IST = +330.
|
|
42
|
+
*/
|
|
43
|
+
tz_offset?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface TimerItem {
|
|
46
|
+
/** 16-char hex ID, generated client-side */
|
|
47
|
+
id: string;
|
|
48
|
+
/** 32-char hex trace ID, shared by parent + children */
|
|
49
|
+
trace_id: string;
|
|
50
|
+
/** ID of the root timer in this trace */
|
|
51
|
+
root_id: string;
|
|
52
|
+
/** 16-char hex ID of parent timer (absent for root timers) */
|
|
53
|
+
parent_id?: string;
|
|
54
|
+
/** Operation name, e.g. 'content-store-startup' */
|
|
55
|
+
name: string;
|
|
56
|
+
/** Timer category. Default: 'client-perf' */
|
|
57
|
+
type: string;
|
|
58
|
+
/** Start time, epoch milliseconds */
|
|
59
|
+
timestamp: number;
|
|
60
|
+
/** Duration in milliseconds */
|
|
61
|
+
duration: number;
|
|
62
|
+
outcome: 'success' | 'failure' | 'unknown';
|
|
63
|
+
context?: {
|
|
64
|
+
tags?: Record<string, JsonValue>;
|
|
65
|
+
};
|
|
66
|
+
/** Minutes east of UTC at record time (see LogEventItem.tz_offset). */
|
|
67
|
+
tz_offset?: number;
|
|
68
|
+
}
|
|
69
|
+
export interface ClientInfo {
|
|
70
|
+
/** Application name, e.g. 'duiduidui-app' */
|
|
71
|
+
name: string;
|
|
72
|
+
/** Application version */
|
|
73
|
+
version: string;
|
|
74
|
+
os: {
|
|
75
|
+
name: string;
|
|
76
|
+
version: string;
|
|
77
|
+
};
|
|
78
|
+
device: {
|
|
79
|
+
model?: string;
|
|
80
|
+
brand?: string;
|
|
81
|
+
type: string;
|
|
82
|
+
};
|
|
83
|
+
runtime: {
|
|
84
|
+
name: string;
|
|
85
|
+
version: string;
|
|
86
|
+
};
|
|
87
|
+
screen?: {
|
|
88
|
+
width: number;
|
|
89
|
+
height: number;
|
|
90
|
+
pixel_ratio: number;
|
|
91
|
+
};
|
|
92
|
+
locale?: string;
|
|
93
|
+
/** IANA timezone name for the session, e.g. 'America/New_York'. */
|
|
94
|
+
timezone?: string;
|
|
95
|
+
device_year_class?: number;
|
|
96
|
+
}
|
package/dist/wire.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@redthreadlabs/tracelog-schema",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared contract for the tracelog suite: record kinds, S3 key layout, metadata sidecar, and the /logs wire format",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"registry": "https://registry.npmjs.org/",
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"license": "BSD-2-Clause",
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"prepublishOnly": "npm run build",
|
|
18
|
+
"pretest": "npm run build",
|
|
19
|
+
"test": "node --test"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "latest"
|
|
23
|
+
}
|
|
24
|
+
}
|