@ontrails/observability 1.0.0-beta.42
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/CHANGELOG.md +202 -0
- package/README.md +40 -0
- package/package.json +46 -0
- package/src/combine.ts +280 -0
- package/src/dev/internal/dev-state.ts +179 -0
- package/src/dev/sampling.ts +30 -0
- package/src/dev/store.ts +260 -0
- package/src/dev/tracing-resource.ts +35 -0
- package/src/dev/tracing-state.ts +47 -0
- package/src/dev/trails/tracing-query.ts +108 -0
- package/src/dev/trails/tracing-status.ts +44 -0
- package/src/dev.ts +37 -0
- package/src/formatters.ts +160 -0
- package/src/index.ts +41 -0
- package/src/memory.ts +61 -0
- package/src/otel.ts +274 -0
- package/src/renderer.ts +373 -0
- package/src/sinks.ts +176 -0
- package/src/testing.ts +49 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
import { ensureSubsystemSchema, openWriteTrailsDb } from '@ontrails/core';
|
|
4
|
+
|
|
5
|
+
import type { DevStoreOptions } from '../store.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_MAX_RECORDS = 10_000;
|
|
8
|
+
export const DEFAULT_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
|
|
9
|
+
export const TRACK_SUBSYSTEM = 'track';
|
|
10
|
+
export const TRACK_TABLE = 'track_records';
|
|
11
|
+
|
|
12
|
+
const CREATE_TABLE_SQL = `CREATE TABLE IF NOT EXISTS ${TRACK_TABLE} (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
trace_id TEXT NOT NULL,
|
|
15
|
+
root_id TEXT NOT NULL,
|
|
16
|
+
parent_id TEXT,
|
|
17
|
+
kind TEXT NOT NULL,
|
|
18
|
+
name TEXT NOT NULL,
|
|
19
|
+
trail_id TEXT,
|
|
20
|
+
surface TEXT,
|
|
21
|
+
intent TEXT,
|
|
22
|
+
started_at INTEGER NOT NULL,
|
|
23
|
+
ended_at INTEGER,
|
|
24
|
+
status TEXT NOT NULL,
|
|
25
|
+
error_category TEXT,
|
|
26
|
+
permit_id TEXT,
|
|
27
|
+
permit_tenant_id TEXT,
|
|
28
|
+
attrs TEXT
|
|
29
|
+
)`;
|
|
30
|
+
|
|
31
|
+
const CREATE_INDEXES_SQL = [
|
|
32
|
+
`CREATE INDEX IF NOT EXISTS idx_${TRACK_TABLE}_trail_id ON ${TRACK_TABLE}(trail_id)`,
|
|
33
|
+
`CREATE INDEX IF NOT EXISTS idx_${TRACK_TABLE}_trace_id ON ${TRACK_TABLE}(trace_id)`,
|
|
34
|
+
`CREATE INDEX IF NOT EXISTS idx_${TRACK_TABLE}_status ON ${TRACK_TABLE}(status)`,
|
|
35
|
+
`CREATE INDEX IF NOT EXISTS idx_${TRACK_TABLE}_started_at ON ${TRACK_TABLE}(started_at)`,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export interface TraceCleanupReport {
|
|
39
|
+
readonly removedByAge: number;
|
|
40
|
+
readonly removedByCount: number;
|
|
41
|
+
readonly removedTotal: number;
|
|
42
|
+
readonly remaining: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const traceTableExists = (db: Database): boolean => {
|
|
46
|
+
const row = db
|
|
47
|
+
.query<{ name: string }, [string]>(
|
|
48
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
|
|
49
|
+
)
|
|
50
|
+
.get(TRACK_TABLE);
|
|
51
|
+
return row?.name === TRACK_TABLE;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const ensureTraceSchema = (db: Database): void => {
|
|
55
|
+
ensureSubsystemSchema(db, {
|
|
56
|
+
migrate: (currentVersion) => {
|
|
57
|
+
if (currentVersion > 0) {
|
|
58
|
+
db.run(`DROP TABLE IF EXISTS ${TRACK_TABLE}`);
|
|
59
|
+
}
|
|
60
|
+
db.run(CREATE_TABLE_SQL);
|
|
61
|
+
for (const sql of CREATE_INDEXES_SQL) {
|
|
62
|
+
db.run(sql);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
subsystem: TRACK_SUBSYSTEM,
|
|
66
|
+
version: 2,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const countTraceRecords = (db: Database): number => {
|
|
71
|
+
if (!traceTableExists(db)) {
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
const result = db
|
|
75
|
+
.query<{ count: number }, []>(
|
|
76
|
+
`SELECT COUNT(*) as count FROM ${TRACK_TABLE}`
|
|
77
|
+
)
|
|
78
|
+
.get();
|
|
79
|
+
return result?.count ?? 0;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const countOldTracks = (db: Database, maxAge: number): number => {
|
|
83
|
+
if (!traceTableExists(db)) {
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
const threshold = Date.now() - maxAge;
|
|
87
|
+
const row = db
|
|
88
|
+
.query<{ count: number }, [number]>(
|
|
89
|
+
`SELECT COUNT(*) as count FROM ${TRACK_TABLE} WHERE started_at < ?`
|
|
90
|
+
)
|
|
91
|
+
.get(threshold);
|
|
92
|
+
return row?.count ?? 0;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const countOverflowTracks = (
|
|
96
|
+
db: Database,
|
|
97
|
+
maxRecords: number,
|
|
98
|
+
maxAge: number
|
|
99
|
+
): number => {
|
|
100
|
+
const total = countTraceRecords(db);
|
|
101
|
+
const remainingAfterAge = total - countOldTracks(db, maxAge);
|
|
102
|
+
return Math.max(remainingAfterAge - maxRecords, 0);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const deleteOldTracks = (db: Database, maxAge: number): number => {
|
|
106
|
+
const threshold = Date.now() - maxAge;
|
|
107
|
+
const result = db.run(`DELETE FROM ${TRACK_TABLE} WHERE started_at < ?`, [
|
|
108
|
+
threshold,
|
|
109
|
+
]);
|
|
110
|
+
return result.changes;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const deleteOverflowTracks = (db: Database, maxRecords: number): number => {
|
|
114
|
+
const excess = Math.max(countTraceRecords(db) - maxRecords, 0);
|
|
115
|
+
if (excess === 0) {
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const result = db.run(
|
|
120
|
+
`DELETE FROM ${TRACK_TABLE} WHERE id IN (
|
|
121
|
+
SELECT id FROM ${TRACK_TABLE} ORDER BY started_at ASC LIMIT ?
|
|
122
|
+
)`,
|
|
123
|
+
[excess]
|
|
124
|
+
);
|
|
125
|
+
return result.changes;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const toCleanupReport = (
|
|
129
|
+
db: Database,
|
|
130
|
+
removedByAge: number,
|
|
131
|
+
removedByCount: number
|
|
132
|
+
): TraceCleanupReport => ({
|
|
133
|
+
remaining: countTraceRecords(db),
|
|
134
|
+
removedByAge,
|
|
135
|
+
removedByCount,
|
|
136
|
+
removedTotal: removedByAge + removedByCount,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
export const previewTraceCleanup = (
|
|
140
|
+
db: Database,
|
|
141
|
+
options?: Pick<DevStoreOptions, 'maxAge' | 'maxRecords'>
|
|
142
|
+
): TraceCleanupReport => {
|
|
143
|
+
const maxRecords = options?.maxRecords ?? DEFAULT_MAX_RECORDS;
|
|
144
|
+
const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
|
|
145
|
+
const removedByAge = countOldTracks(db, maxAge);
|
|
146
|
+
const removedByCount = countOverflowTracks(db, maxRecords, maxAge);
|
|
147
|
+
return toCleanupReport(db, removedByAge, removedByCount);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const applyTraceCleanup = (
|
|
151
|
+
db: Database,
|
|
152
|
+
options?: Pick<DevStoreOptions, 'maxAge' | 'maxRecords'>
|
|
153
|
+
): TraceCleanupReport => {
|
|
154
|
+
if (!traceTableExists(db)) {
|
|
155
|
+
return toCleanupReport(db, 0, 0);
|
|
156
|
+
}
|
|
157
|
+
const maxRecords = options?.maxRecords ?? DEFAULT_MAX_RECORDS;
|
|
158
|
+
const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
|
|
159
|
+
const removedByAge = deleteOldTracks(db, maxAge);
|
|
160
|
+
const removedByCount = deleteOverflowTracks(db, maxRecords);
|
|
161
|
+
return toCleanupReport(db, removedByAge, removedByCount);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const withTraceStoreDb = <T>(
|
|
165
|
+
options: Pick<DevStoreOptions, 'path' | 'rootDir'> | undefined,
|
|
166
|
+
run: (db: Database) => T
|
|
167
|
+
): T => {
|
|
168
|
+
const db = openWriteTrailsDb({
|
|
169
|
+
...(options?.path === undefined ? {} : { path: options.path }),
|
|
170
|
+
...(options?.rootDir === undefined ? {} : { rootDir: options.rootDir }),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
ensureTraceSchema(db);
|
|
175
|
+
return run(db);
|
|
176
|
+
} finally {
|
|
177
|
+
db.close();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Intent-based sampling rate configuration. */
|
|
2
|
+
export interface SamplingConfig {
|
|
3
|
+
/** Sample rate for read operations (0.0 to 1.0). Default 0.05 (5%). */
|
|
4
|
+
readonly read: number;
|
|
5
|
+
/** Sample rate for write operations (0.0 to 1.0). Default 1.0 (100%). */
|
|
6
|
+
readonly write: number;
|
|
7
|
+
/** Sample rate for destroy operations (0.0 to 1.0). Default 1.0 (100%). */
|
|
8
|
+
readonly destroy: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Default sampling rates: 5% reads, 100% writes and destroys. */
|
|
12
|
+
export const DEFAULT_SAMPLING: SamplingConfig = {
|
|
13
|
+
destroy: 1,
|
|
14
|
+
read: 0.05,
|
|
15
|
+
write: 1,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decide whether to sample a trace based on intent.
|
|
20
|
+
*
|
|
21
|
+
* Undefined intent falls back to the write rate.
|
|
22
|
+
*/
|
|
23
|
+
export const shouldSample = (
|
|
24
|
+
intent: 'read' | 'write' | 'destroy' | undefined,
|
|
25
|
+
config?: Partial<SamplingConfig>
|
|
26
|
+
): boolean => {
|
|
27
|
+
const merged = { ...DEFAULT_SAMPLING, ...config };
|
|
28
|
+
const rate = merged[intent ?? 'write'];
|
|
29
|
+
return Math.random() < rate;
|
|
30
|
+
};
|
package/src/dev/store.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { Database, SQLQueryBindings } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
import { openWriteTrailsDb } from '@ontrails/core';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_MAX_AGE,
|
|
7
|
+
DEFAULT_MAX_RECORDS,
|
|
8
|
+
TRACK_TABLE,
|
|
9
|
+
applyTraceCleanup,
|
|
10
|
+
countTraceRecords,
|
|
11
|
+
ensureTraceSchema,
|
|
12
|
+
} from './internal/dev-state.js';
|
|
13
|
+
|
|
14
|
+
import type { TraceRecord, TraceSink } from '@ontrails/core';
|
|
15
|
+
|
|
16
|
+
/** Configuration for the SQLite dev store. */
|
|
17
|
+
export interface DevStoreOptions {
|
|
18
|
+
/** Environment used for state-store discovery. Defaults to `process.env`. */
|
|
19
|
+
readonly env?: Record<string, string | undefined>;
|
|
20
|
+
/** Path to the SQLite database file. Overrides state-store discovery. */
|
|
21
|
+
readonly path?: string;
|
|
22
|
+
/** Root directory used when deriving the default per-project state path. */
|
|
23
|
+
readonly rootDir?: string;
|
|
24
|
+
/** Maximum number of records to retain. Defaults to 10000. */
|
|
25
|
+
readonly maxRecords?: number;
|
|
26
|
+
/** Maximum age of records in milliseconds. Defaults to 7 days. */
|
|
27
|
+
readonly maxAge?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Query options for filtering stored track records. */
|
|
31
|
+
export interface DevStoreQueryOptions {
|
|
32
|
+
readonly trailId?: string;
|
|
33
|
+
readonly traceId?: string;
|
|
34
|
+
readonly errorsOnly?: boolean;
|
|
35
|
+
readonly limit?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read-only query surface over persisted track records. */
|
|
39
|
+
export interface TraceStore {
|
|
40
|
+
/** Query recent traces with optional filters. */
|
|
41
|
+
readonly query: (options?: DevStoreQueryOptions) => readonly TraceRecord[];
|
|
42
|
+
/** Return the total number of stored records. */
|
|
43
|
+
readonly count: () => number;
|
|
44
|
+
/** Close the database connection. */
|
|
45
|
+
readonly close: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** SQLite-backed dev store for persisting and querying track records. */
|
|
49
|
+
export interface DevStore extends TraceStore, TraceSink {}
|
|
50
|
+
|
|
51
|
+
/** Shape of a row returned from the tracing table. */
|
|
52
|
+
interface TraceRow {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
readonly trace_id: string;
|
|
55
|
+
readonly root_id: string;
|
|
56
|
+
readonly parent_id: string | null;
|
|
57
|
+
readonly kind: string;
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly trail_id: string | null;
|
|
60
|
+
readonly surface: string | null;
|
|
61
|
+
readonly intent: string | null;
|
|
62
|
+
readonly started_at: number;
|
|
63
|
+
readonly ended_at: number | null;
|
|
64
|
+
readonly status: string;
|
|
65
|
+
readonly error_category: string | null;
|
|
66
|
+
readonly permit_id: string | null;
|
|
67
|
+
readonly permit_tenant_id: string | null;
|
|
68
|
+
readonly attrs: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Reconstruct the permit object from decomposed columns. */
|
|
72
|
+
const buildPermit = (
|
|
73
|
+
permitId: string | null,
|
|
74
|
+
tenantId: string | null
|
|
75
|
+
): TraceRecord['permit'] => {
|
|
76
|
+
if (permitId === null) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return tenantId === null ? { id: permitId } : { id: permitId, tenantId };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Parse attrs JSON back into a record. */
|
|
83
|
+
const parseAttrs = (raw: string | null): Readonly<Record<string, unknown>> =>
|
|
84
|
+
raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
|
85
|
+
|
|
86
|
+
/** Reconstruct a TraceRecord from a database row. */
|
|
87
|
+
const rowToRecord = (row: TraceRow): TraceRecord => ({
|
|
88
|
+
attrs: parseAttrs(row.attrs),
|
|
89
|
+
endedAt: row.ended_at ?? undefined,
|
|
90
|
+
errorCategory: row.error_category ?? undefined,
|
|
91
|
+
id: row.id,
|
|
92
|
+
intent: (row.intent ?? undefined) as TraceRecord['intent'],
|
|
93
|
+
kind: row.kind as TraceRecord['kind'],
|
|
94
|
+
name: row.name,
|
|
95
|
+
parentId: row.parent_id ?? undefined,
|
|
96
|
+
permit: buildPermit(row.permit_id, row.permit_tenant_id),
|
|
97
|
+
rootId: row.root_id,
|
|
98
|
+
startedAt: row.started_at,
|
|
99
|
+
status: row.status as TraceRecord['status'],
|
|
100
|
+
surface: (row.surface ?? undefined) as TraceRecord['surface'],
|
|
101
|
+
traceId: row.trace_id,
|
|
102
|
+
trailId: row.trail_id ?? undefined,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** Filter definition: column condition and optional bound value. */
|
|
106
|
+
interface QueryFilter {
|
|
107
|
+
readonly condition: string;
|
|
108
|
+
readonly value?: SQLQueryBindings;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Derive active filters from query options. */
|
|
112
|
+
const deriveFilters = (
|
|
113
|
+
options?: DevStoreQueryOptions
|
|
114
|
+
): readonly QueryFilter[] => {
|
|
115
|
+
const filters: QueryFilter[] = [];
|
|
116
|
+
|
|
117
|
+
if (options?.trailId !== undefined) {
|
|
118
|
+
filters.push({ condition: 'trail_id = ?', value: options.trailId });
|
|
119
|
+
}
|
|
120
|
+
if (options?.traceId !== undefined) {
|
|
121
|
+
filters.push({ condition: 'trace_id = ?', value: options.traceId });
|
|
122
|
+
}
|
|
123
|
+
if (options?.errorsOnly === true) {
|
|
124
|
+
filters.push({ condition: "status = 'err'" });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return filters;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Build a parameterized SELECT query from query options. */
|
|
131
|
+
const buildQuery = (
|
|
132
|
+
defaultLimit: number,
|
|
133
|
+
options?: DevStoreQueryOptions
|
|
134
|
+
): { readonly sql: string; readonly params: SQLQueryBindings[] } => {
|
|
135
|
+
const filters = deriveFilters(options);
|
|
136
|
+
const where =
|
|
137
|
+
filters.length > 0
|
|
138
|
+
? `WHERE ${filters.map((f) => f.condition).join(' AND ')}`
|
|
139
|
+
: '';
|
|
140
|
+
const params: SQLQueryBindings[] = [
|
|
141
|
+
...filters.flatMap((f) => (f.value === undefined ? [] : [f.value])),
|
|
142
|
+
options?.limit ?? defaultLimit,
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
params,
|
|
147
|
+
sql: `SELECT * FROM ${TRACK_TABLE} ${where} ORDER BY started_at DESC LIMIT ?`,
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Serialize attrs to JSON, returning null for empty objects. */
|
|
152
|
+
const serializeAttrs = (
|
|
153
|
+
attrs: Readonly<Record<string, unknown>>
|
|
154
|
+
): string | null =>
|
|
155
|
+
Object.keys(attrs).length > 0 ? JSON.stringify(attrs) : null;
|
|
156
|
+
|
|
157
|
+
/** Serialize a TraceRecord into positional INSERT parameters. */
|
|
158
|
+
const recordToParams = (record: TraceRecord): SQLQueryBindings[] => [
|
|
159
|
+
record.id,
|
|
160
|
+
record.traceId,
|
|
161
|
+
record.rootId,
|
|
162
|
+
record.parentId ?? null,
|
|
163
|
+
record.kind,
|
|
164
|
+
record.name,
|
|
165
|
+
record.trailId ?? null,
|
|
166
|
+
record.surface ?? null,
|
|
167
|
+
record.intent ?? null,
|
|
168
|
+
record.startedAt,
|
|
169
|
+
record.endedAt ?? null,
|
|
170
|
+
record.status,
|
|
171
|
+
record.errorCategory ?? null,
|
|
172
|
+
record.permit?.id ?? null,
|
|
173
|
+
record.permit?.tenantId ?? null,
|
|
174
|
+
serializeAttrs(record.attrs),
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
/** SQL for inserting a track record. */
|
|
178
|
+
const UPSERT_SQL = `INSERT INTO ${TRACK_TABLE} (
|
|
179
|
+
id, trace_id, root_id, parent_id,
|
|
180
|
+
kind, name, trail_id, surface,
|
|
181
|
+
intent, started_at, ended_at, status,
|
|
182
|
+
error_category, permit_id, permit_tenant_id, attrs
|
|
183
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
184
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
185
|
+
trace_id = excluded.trace_id,
|
|
186
|
+
root_id = excluded.root_id,
|
|
187
|
+
parent_id = excluded.parent_id,
|
|
188
|
+
kind = excluded.kind,
|
|
189
|
+
name = excluded.name,
|
|
190
|
+
trail_id = excluded.trail_id,
|
|
191
|
+
surface = excluded.surface,
|
|
192
|
+
intent = excluded.intent,
|
|
193
|
+
started_at = excluded.started_at,
|
|
194
|
+
ended_at = excluded.ended_at,
|
|
195
|
+
status = excluded.status,
|
|
196
|
+
error_category = excluded.error_category,
|
|
197
|
+
permit_id = excluded.permit_id,
|
|
198
|
+
permit_tenant_id = excluded.permit_tenant_id,
|
|
199
|
+
attrs = excluded.attrs`;
|
|
200
|
+
|
|
201
|
+
/** Create a transactional writer that keeps retention pruning atomic. */
|
|
202
|
+
const createWriter = (
|
|
203
|
+
db: Database,
|
|
204
|
+
insertStmt: ReturnType<Database['prepare']>,
|
|
205
|
+
maxRecords: number,
|
|
206
|
+
maxAge: number | undefined
|
|
207
|
+
): ((record: TraceRecord) => void) =>
|
|
208
|
+
db.transaction((record: TraceRecord) => {
|
|
209
|
+
insertStmt.run(...recordToParams(record));
|
|
210
|
+
applyTraceCleanup(db, {
|
|
211
|
+
maxRecords,
|
|
212
|
+
...(maxAge === undefined ? {} : { maxAge }),
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const createReadApi = (db: Database, defaultLimit: number): TraceStore => ({
|
|
217
|
+
close: () => {
|
|
218
|
+
db.close();
|
|
219
|
+
},
|
|
220
|
+
count: () => countTraceRecords(db),
|
|
221
|
+
query: (queryOptions?: DevStoreQueryOptions): readonly TraceRecord[] => {
|
|
222
|
+
const { sql, params } = buildQuery(defaultLimit, queryOptions);
|
|
223
|
+
const rows = db.query<TraceRow, SQLQueryBindings[]>(sql).all(...params);
|
|
224
|
+
return rows.map(rowToRecord);
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create a SQLite-backed dev store for persisting track records.
|
|
230
|
+
*
|
|
231
|
+
* Uses WAL mode and normal synchronous for good write performance.
|
|
232
|
+
* Automatically prunes records exceeding `maxRecords` on each write.
|
|
233
|
+
*/
|
|
234
|
+
export const createDevStore = (options?: DevStoreOptions): DevStore => {
|
|
235
|
+
const maxRecords = options?.maxRecords ?? DEFAULT_MAX_RECORDS;
|
|
236
|
+
const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
|
|
237
|
+
const db = openWriteTrailsDb({
|
|
238
|
+
...(options?.env === undefined ? {} : { env: options.env }),
|
|
239
|
+
...(options?.path === undefined ? {} : { path: options.path }),
|
|
240
|
+
...(options?.rootDir === undefined ? {} : { rootDir: options.rootDir }),
|
|
241
|
+
});
|
|
242
|
+
ensureTraceSchema(db);
|
|
243
|
+
const insertStmt = db.prepare(UPSERT_SQL);
|
|
244
|
+
const write = createWriter(db, insertStmt, maxRecords, maxAge);
|
|
245
|
+
return { ...createReadApi(db, maxRecords), write };
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Read-only view of a TraceStore.
|
|
250
|
+
*
|
|
251
|
+
* `close()` is a no-op — consumers of this view (e.g. the tracing resource)
|
|
252
|
+
* must not close the underlying connection they don't own.
|
|
253
|
+
*/
|
|
254
|
+
export const toTraceStore = (store: TraceStore): TraceStore => ({
|
|
255
|
+
close: () => {
|
|
256
|
+
// Intentional no-op: read-only view must not close the underlying DB.
|
|
257
|
+
},
|
|
258
|
+
count: () => store.count(),
|
|
259
|
+
query: (options?: DevStoreQueryOptions) => store.query(options),
|
|
260
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Result, resource } from '@ontrails/core';
|
|
2
|
+
|
|
3
|
+
import type { TracingState } from './tracing-state.js';
|
|
4
|
+
import { getTracingState } from './tracing-state.js';
|
|
5
|
+
import { DEFAULT_SAMPLING } from './sampling.js';
|
|
6
|
+
import { toTraceStore } from './store.js';
|
|
7
|
+
|
|
8
|
+
/** Default state when no explicit state has been registered. */
|
|
9
|
+
const defaultState: TracingState = {
|
|
10
|
+
active: true,
|
|
11
|
+
sampling: DEFAULT_SAMPLING,
|
|
12
|
+
store: undefined,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Telemetry query resource.
|
|
17
|
+
*
|
|
18
|
+
* Exposes the current tracing store, sampling config, and active flag as a
|
|
19
|
+
* single `TracingState` accessible to trails via `tracingResource.from(ctx)`.
|
|
20
|
+
*
|
|
21
|
+
* Unlike config, tracing gracefully defaults when no state is registered —
|
|
22
|
+
* telemetry should never fail to start.
|
|
23
|
+
*/
|
|
24
|
+
export const tracingResource = resource<TracingState>('tracing', {
|
|
25
|
+
create: () => {
|
|
26
|
+
const state = getTracingState() ?? defaultState;
|
|
27
|
+
return Result.ok({
|
|
28
|
+
...state,
|
|
29
|
+
store: state.store ? toTraceStore(state.store) : undefined,
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
description: 'Telemetry query resource',
|
|
33
|
+
meta: { category: 'infrastructure' },
|
|
34
|
+
mock: (): TracingState => ({ ...defaultState }),
|
|
35
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { DEFAULT_SAMPLING } from './sampling.js';
|
|
2
|
+
import type { SamplingConfig } from './sampling.js';
|
|
3
|
+
import type { TraceStore } from './store.js';
|
|
4
|
+
|
|
5
|
+
/** Full telemetry subsystem state carried by tracingResource. */
|
|
6
|
+
export interface TracingState {
|
|
7
|
+
readonly active: boolean;
|
|
8
|
+
readonly sampling: SamplingConfig;
|
|
9
|
+
readonly store: TraceStore | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// oxlint-disable-next-line eslint-plugin-jest/require-hook -- module-level state registry, not test setup
|
|
13
|
+
let state: TracingState | undefined;
|
|
14
|
+
|
|
15
|
+
/** Register telemetry state at bootstrap. */
|
|
16
|
+
export const registerTracingState = (s: TracingState): void => {
|
|
17
|
+
state = s;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Read the registered telemetry state. Returns `undefined` before registration. */
|
|
21
|
+
export const getTracingState = (): TracingState | undefined => state;
|
|
22
|
+
|
|
23
|
+
/** Clear registered state. Primarily useful in tests. */
|
|
24
|
+
export const clearTracingState = (): void => {
|
|
25
|
+
state = undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// --- Convenience wrappers for store-only registration ---
|
|
29
|
+
|
|
30
|
+
/** Register a trace store instance for use by the tracing.query trail. */
|
|
31
|
+
export const registerTraceStore = (s: TraceStore): void => {
|
|
32
|
+
state = {
|
|
33
|
+
active: state?.active ?? true,
|
|
34
|
+
sampling: state?.sampling ?? DEFAULT_SAMPLING,
|
|
35
|
+
store: s,
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Retrieve the currently registered trace store, if any. */
|
|
40
|
+
export const getTraceStore = (): TraceStore | undefined => state?.store;
|
|
41
|
+
|
|
42
|
+
/** Clear the registered store. Useful for testing teardown. */
|
|
43
|
+
export const clearTraceStore = (): void => {
|
|
44
|
+
if (state) {
|
|
45
|
+
state = { ...state, store: undefined };
|
|
46
|
+
}
|
|
47
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Result, trail } from '@ontrails/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import type { DevStoreQueryOptions } from '../store.js';
|
|
5
|
+
import { tracingResource } from '../tracing-resource.js';
|
|
6
|
+
|
|
7
|
+
/** Output schema for individual trace records. */
|
|
8
|
+
const traceRecordOutput = z.object({
|
|
9
|
+
attrs: z.record(z.string(), z.unknown()),
|
|
10
|
+
endedAt: z.number().optional(),
|
|
11
|
+
errorCategory: z.string().optional(),
|
|
12
|
+
id: z.string(),
|
|
13
|
+
intent: z.string().optional(),
|
|
14
|
+
kind: z.enum(['activation', 'signal', 'span', 'trail']),
|
|
15
|
+
name: z.string(),
|
|
16
|
+
parentId: z.string().optional(),
|
|
17
|
+
rootId: z.string(),
|
|
18
|
+
startedAt: z.number(),
|
|
19
|
+
status: z.enum(['ok', 'err', 'cancelled']),
|
|
20
|
+
surface: z.string().optional(),
|
|
21
|
+
traceId: z.string(),
|
|
22
|
+
trailId: z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Output schema for the tracing.query trail. */
|
|
26
|
+
const tracingQueryOutput = z.object({
|
|
27
|
+
count: z.number(),
|
|
28
|
+
records: z.array(traceRecordOutput),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Map a TraceRecord to the output shape, dropping internal fields. */
|
|
32
|
+
const mapRecord = (r: {
|
|
33
|
+
readonly attrs: Readonly<Record<string, unknown>>;
|
|
34
|
+
readonly endedAt?: number | undefined;
|
|
35
|
+
readonly errorCategory?: string | undefined;
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly intent?: string | undefined;
|
|
38
|
+
readonly kind: 'activation' | 'signal' | 'span' | 'trail';
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly parentId?: string | undefined;
|
|
41
|
+
readonly rootId: string;
|
|
42
|
+
readonly startedAt: number;
|
|
43
|
+
readonly status: 'ok' | 'err' | 'cancelled';
|
|
44
|
+
readonly surface?: string | undefined;
|
|
45
|
+
readonly traceId: string;
|
|
46
|
+
readonly trailId?: string | undefined;
|
|
47
|
+
}) => ({
|
|
48
|
+
attrs: r.attrs,
|
|
49
|
+
endedAt: r.endedAt,
|
|
50
|
+
errorCategory: r.errorCategory,
|
|
51
|
+
id: r.id,
|
|
52
|
+
intent: r.intent,
|
|
53
|
+
kind: r.kind,
|
|
54
|
+
name: r.name,
|
|
55
|
+
parentId: r.parentId,
|
|
56
|
+
rootId: r.rootId,
|
|
57
|
+
startedAt: r.startedAt,
|
|
58
|
+
status: r.status,
|
|
59
|
+
surface: r.surface,
|
|
60
|
+
traceId: r.traceId,
|
|
61
|
+
trailId: r.trailId,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Build DevStoreQueryOptions, omitting undefined fields for exactOptionalPropertyTypes. */
|
|
65
|
+
const buildQueryOptions = (input: {
|
|
66
|
+
readonly errorsOnly: boolean;
|
|
67
|
+
readonly limit: number;
|
|
68
|
+
readonly traceId?: string | undefined;
|
|
69
|
+
readonly trailId?: string | undefined;
|
|
70
|
+
}): DevStoreQueryOptions => ({
|
|
71
|
+
errorsOnly: input.errorsOnly,
|
|
72
|
+
limit: input.limit,
|
|
73
|
+
...(input.trailId === undefined ? {} : { trailId: input.trailId }),
|
|
74
|
+
...(input.traceId === undefined ? {} : { traceId: input.traceId }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Query execution history from the tracing dev store.
|
|
79
|
+
*
|
|
80
|
+
* Reads the store from the `tracingResource` state. Returns an empty
|
|
81
|
+
* result set when no store has been configured.
|
|
82
|
+
*/
|
|
83
|
+
export const tracingQuery = trail('tracing.query', {
|
|
84
|
+
examples: [
|
|
85
|
+
{ input: {}, name: 'Recent traces' },
|
|
86
|
+
{ input: { trailId: 'user.create' }, name: 'Filter by trail' },
|
|
87
|
+
{ input: { errorsOnly: true }, name: 'Errors only' },
|
|
88
|
+
],
|
|
89
|
+
implementation: (input, ctx) => {
|
|
90
|
+
const state = tracingResource.from(ctx);
|
|
91
|
+
if (!state.store) {
|
|
92
|
+
return Result.ok({ count: 0, records: [] });
|
|
93
|
+
}
|
|
94
|
+
const records = state.store.query(buildQueryOptions(input));
|
|
95
|
+
const mapped = records.map(mapRecord);
|
|
96
|
+
return Result.ok({ count: mapped.length, records: mapped });
|
|
97
|
+
},
|
|
98
|
+
input: z.object({
|
|
99
|
+
errorsOnly: z.boolean().describe('Show only failed traces').default(false),
|
|
100
|
+
limit: z.number().describe('Max results').default(20),
|
|
101
|
+
traceId: z.string().describe('Show full trace tree').optional(),
|
|
102
|
+
trailId: z.string().describe('Filter by trail ID').optional(),
|
|
103
|
+
}),
|
|
104
|
+
intent: 'read',
|
|
105
|
+
meta: { category: 'infrastructure' },
|
|
106
|
+
output: tracingQueryOutput,
|
|
107
|
+
resources: [tracingResource],
|
|
108
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Result, trail } from '@ontrails/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import { tracingResource } from '../tracing-resource.js';
|
|
5
|
+
|
|
6
|
+
/** Output schema for the tracing.status trail. */
|
|
7
|
+
const tracingStatusOutput = z.object({
|
|
8
|
+
active: z.boolean(),
|
|
9
|
+
recordCount: z.number(),
|
|
10
|
+
samplingConfig: z.object({
|
|
11
|
+
destroy: z.number(),
|
|
12
|
+
read: z.number(),
|
|
13
|
+
write: z.number(),
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reports the current status of the tracing telemetry subsystem.
|
|
19
|
+
*
|
|
20
|
+
* Returns whether tracing is active, the current record count, and
|
|
21
|
+
* the sampling configuration for each intent. Reads all values from
|
|
22
|
+
* the `tracingResource` state.
|
|
23
|
+
*/
|
|
24
|
+
export const tracingStatus = trail('tracing.status', {
|
|
25
|
+
examples: [
|
|
26
|
+
{
|
|
27
|
+
input: {},
|
|
28
|
+
name: 'Check tracing status',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
implementation: (_input, ctx) => {
|
|
32
|
+
const state = tracingResource.from(ctx);
|
|
33
|
+
return Result.ok({
|
|
34
|
+
active: state.active,
|
|
35
|
+
recordCount: state.store?.count() ?? 0,
|
|
36
|
+
samplingConfig: { ...state.sampling },
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
input: z.object({}),
|
|
40
|
+
intent: 'read',
|
|
41
|
+
meta: { category: 'infrastructure' },
|
|
42
|
+
output: tracingStatusOutput,
|
|
43
|
+
resources: [tracingResource],
|
|
44
|
+
});
|