ironside 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/src/batcher.ts ADDED
@@ -0,0 +1,109 @@
1
+ import type { IngestRequestEvent } from "./types.js";
2
+
3
+ export interface BatcherOptions {
4
+ apiKey: string;
5
+ host: string;
6
+ /** Flush automatically once this many events are buffered. */
7
+ maxBatchSize?: number;
8
+ /** Flush automatically after this many ms, even if under maxBatchSize. */
9
+ flushIntervalMs?: number;
10
+ /** Injectable for tests; defaults to global fetch. */
11
+ fetchImpl?: typeof fetch;
12
+ /** Called with a failed batch's events + the error; default: console.error. Never throws back into the caller's hot path. */
13
+ onError?: (error: unknown, events: IngestRequestEvent[]) => void;
14
+ }
15
+
16
+ const DEFAULT_MAX_BATCH_SIZE = 50;
17
+ const DEFAULT_FLUSH_INTERVAL_MS = 5000;
18
+
19
+ /**
20
+ * Buffers ingest events in memory and flushes them to POST /api/v1/ingest
21
+ * in the background — instrumentation calls (trace/span/generation) never
22
+ * block on network I/O. Flushes are fire-and-forget from the caller's
23
+ * perspective; failures are reported via onError, not thrown, since a
24
+ * trace SDK must never be the reason an application request fails.
25
+ */
26
+ export class EventBatcher {
27
+ private readonly apiKey: string;
28
+ private readonly host: string;
29
+ private readonly maxBatchSize: number;
30
+ private readonly flushIntervalMs: number;
31
+ private readonly fetchImpl: typeof fetch;
32
+ private readonly onError: (error: unknown, events: IngestRequestEvent[]) => void;
33
+
34
+ private buffer: IngestRequestEvent[] = [];
35
+ private timer: ReturnType<typeof setInterval> | null = null;
36
+ private inFlight: Promise<void> = Promise.resolve();
37
+ private closed = false;
38
+
39
+ constructor(options: BatcherOptions) {
40
+ this.apiKey = options.apiKey;
41
+ this.host = options.host.replace(/\/$/, "");
42
+ this.maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
43
+ this.flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
44
+ this.fetchImpl = options.fetchImpl ?? fetch;
45
+ this.onError =
46
+ options.onError ??
47
+ ((error) => console.error("[ironside] failed to send trace events:", error));
48
+
49
+ this.timer = setInterval(() => void this.flush(), this.flushIntervalMs);
50
+ // Don't let the flush timer keep the process alive on its own.
51
+ this.timer.unref?.();
52
+ }
53
+
54
+ enqueue(event: IngestRequestEvent): void {
55
+ if (this.closed) return;
56
+ this.buffer.push(event);
57
+ if (this.buffer.length >= this.maxBatchSize) {
58
+ void this.flush();
59
+ }
60
+ }
61
+
62
+ /** Sends whatever is currently buffered. Safe to call concurrently — flushes serialize via inFlight. */
63
+ async flush(): Promise<void> {
64
+ if (this.buffer.length === 0) return;
65
+ const events = this.buffer;
66
+ this.buffer = [];
67
+
68
+ this.inFlight = this.inFlight.then(() => this.send(events));
69
+ await this.inFlight;
70
+ }
71
+
72
+ private async send(events: IngestRequestEvent[]): Promise<void> {
73
+ try {
74
+ const res = await this.fetchImpl(`${this.host}/api/v1/ingest`, {
75
+ method: "POST",
76
+ headers: {
77
+ "content-type": "application/json",
78
+ authorization: `Bearer ${this.apiKey}`
79
+ },
80
+ body: JSON.stringify({ events })
81
+ });
82
+ if (!res.ok) {
83
+ this.onError(new Error(`ingest request failed: HTTP ${res.status}`), events);
84
+ }
85
+ } catch (error) {
86
+ this.onError(error, events);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Stops the background timer and flushes any remaining buffered events.
92
+ * Call on process shutdown.
93
+ *
94
+ * Must wait on `inFlight` explicitly, not just call `flush()` — if a
95
+ * different caller (the interval timer, or an enqueue() that just hit
96
+ * maxBatchSize) already claimed the buffer into its own in-progress
97
+ * send() moments earlier, flush() here sees an empty buffer and returns
98
+ * immediately without waiting for that still-pending request. Without
99
+ * this, shutdown() could resolve while a real network request is still
100
+ * in flight, and an immediately-following process.exit() would silently
101
+ * drop that batch.
102
+ */
103
+ async close(): Promise<void> {
104
+ this.closed = true;
105
+ if (this.timer) clearInterval(this.timer);
106
+ await this.flush();
107
+ await this.inFlight;
108
+ }
109
+ }
package/src/client.ts ADDED
@@ -0,0 +1,267 @@
1
+ import { ulid } from "ulid";
2
+ import { EventBatcher, type BatcherOptions } from "./batcher.js";
3
+ import type {
4
+ EndObservationOptions,
5
+ ScoreOptions,
6
+ StartGenerationOptions,
7
+ StartSpanOptions,
8
+ StartTraceOptions,
9
+ UpdateTraceOptions
10
+ } from "./types.js";
11
+
12
+ export interface IronsideClientOptions {
13
+ apiKey: string;
14
+ /** Ironside API host, e.g. "https://ironside.example.com" or "http://localhost:8788". */
15
+ host: string;
16
+ maxBatchSize?: number;
17
+ flushIntervalMs?: number;
18
+ fetchImpl?: BatcherOptions["fetchImpl"];
19
+ onError?: BatcherOptions["onError"];
20
+ }
21
+
22
+ export interface ObservationHandle {
23
+ readonly id: string;
24
+ /** Starts a child span nested under this observation. */
25
+ span(options?: StartSpanOptions): ObservationHandle;
26
+ /** Starts a child generation nested under this observation. */
27
+ generation(options?: StartGenerationOptions): ObservationHandle;
28
+ /** Marks this observation complete. Safe to call at most once meaningfully; a second call overwrites endTime. */
29
+ end(options?: EndObservationOptions): void;
30
+ /** Records a score (user feedback, eval result, ...) attached to this observation. */
31
+ score(options: ScoreOptions): void;
32
+ }
33
+
34
+ export interface TraceHandle {
35
+ readonly id: string;
36
+ span(options?: StartSpanOptions): ObservationHandle;
37
+ generation(options?: StartGenerationOptions): ObservationHandle;
38
+ update(options: UpdateTraceOptions): void;
39
+ /** Records a score (user feedback, eval result, ...) attached to this trace. */
40
+ score(options: ScoreOptions): void;
41
+ }
42
+
43
+ export interface UploadMediaOptions {
44
+ /** The raw bytes to store. */
45
+ data: Uint8Array | ArrayBuffer;
46
+ /** Real content type of the bytes, e.g. "image/png". */
47
+ contentType: string;
48
+ }
49
+
50
+ export interface UploadedMedia {
51
+ id: string;
52
+ /** Compact ref string ("ironside://media/<id>") to embed in trace input/output/metadata. */
53
+ ref: string;
54
+ contentType: string;
55
+ sizeBytes: number;
56
+ sha256: string;
57
+ }
58
+
59
+ export interface IronsideClient {
60
+ trace(options?: StartTraceOptions): TraceHandle;
61
+ /**
62
+ * Uploads a media blob (image, audio, document, ...) and returns a
63
+ * compact ref string to embed in trace input/output instead of the
64
+ * bytes themselves — base64 payloads inside trace JSON bloat the
65
+ * columnar store. Content-addressed: uploading identical bytes twice
66
+ * returns the same asset. Unlike instrumentation calls this awaits the
67
+ * network (the ref doesn't exist until the server has the bytes).
68
+ */
69
+ uploadMedia(options: UploadMediaOptions): Promise<UploadedMedia>;
70
+ /** Sends buffered events immediately instead of waiting for the next automatic flush. */
71
+ flush(): Promise<void>;
72
+ /** Stops background flushing and sends any remaining buffered events. Call before process exit. */
73
+ shutdown(): Promise<void>;
74
+ }
75
+
76
+ /**
77
+ * Creates an Ironside client. Instrumentation calls (trace/span/generation)
78
+ * never block on network I/O — events are buffered and flushed in the
79
+ * background. Deliberately does NOT auto-register a process-exit handler:
80
+ * doing that inside a library is a footgun (can't be un-registered,
81
+ * surprising under multiple init() calls or in serverless runtimes with
82
+ * their own lifecycle hooks). Call `shutdown()` explicitly wherever your
83
+ * app already handles graceful shutdown.
84
+ */
85
+ export function init(options: IronsideClientOptions): IronsideClient {
86
+ const batcher = new EventBatcher({
87
+ apiKey: options.apiKey,
88
+ host: options.host,
89
+ ...(options.maxBatchSize !== undefined && { maxBatchSize: options.maxBatchSize }),
90
+ ...(options.flushIntervalMs !== undefined && { flushIntervalMs: options.flushIntervalMs }),
91
+ ...(options.fetchImpl !== undefined && { fetchImpl: options.fetchImpl }),
92
+ ...(options.onError !== undefined && { onError: options.onError })
93
+ });
94
+
95
+ function enqueueScore(
96
+ traceId: string,
97
+ observationId: string | undefined,
98
+ scoreOptions: ScoreOptions
99
+ ): void {
100
+ batcher.enqueue({
101
+ type: "score-upsert",
102
+ body: {
103
+ id: scoreOptions.id ?? ulid(),
104
+ traceId,
105
+ ...(observationId && { observationId }),
106
+ name: scoreOptions.name,
107
+ dataType: scoreOptions.value !== undefined ? "numeric" : "categorical",
108
+ source: scoreOptions.source ?? "api",
109
+ ...(scoreOptions.value !== undefined && { value: scoreOptions.value }),
110
+ ...(scoreOptions.stringValue !== undefined && { stringValue: scoreOptions.stringValue }),
111
+ ...(scoreOptions.comment && { comment: scoreOptions.comment }),
112
+ timestamp: new Date().toISOString(),
113
+ metadata: scoreOptions.metadata ?? {}
114
+ }
115
+ });
116
+ }
117
+
118
+ function makeObservationHandle(
119
+ id: string,
120
+ traceId: string,
121
+ type: "span" | "generation",
122
+ parentObservationId: string | undefined,
123
+ startOptions: StartSpanOptions | StartGenerationOptions
124
+ ): ObservationHandle {
125
+ const startTime = new Date().toISOString();
126
+ const model = "model" in startOptions ? startOptions.model : undefined;
127
+ const modelParameters =
128
+ "modelParameters" in startOptions ? startOptions.modelParameters : undefined;
129
+
130
+ batcher.enqueue({
131
+ type: "observation-upsert",
132
+ body: {
133
+ id,
134
+ traceId,
135
+ ...(parentObservationId && { parentObservationId }),
136
+ type,
137
+ ...(startOptions.name && { name: startOptions.name }),
138
+ startTime,
139
+ ...(model && { model }),
140
+ ...(modelParameters && { modelParameters }),
141
+ ...(startOptions.input !== undefined && { input: startOptions.input }),
142
+ metadata: startOptions.metadata ?? {}
143
+ }
144
+ });
145
+
146
+ function child(childType: "span" | "generation") {
147
+ return (options: StartSpanOptions | StartGenerationOptions = {}) =>
148
+ makeObservationHandle(ulid(), traceId, childType, id, options);
149
+ }
150
+
151
+ return {
152
+ id,
153
+ span: child("span"),
154
+ generation: child("generation"),
155
+ end(endOptions: EndObservationOptions = {}) {
156
+ batcher.enqueue({
157
+ type: "observation-upsert",
158
+ body: {
159
+ id,
160
+ traceId,
161
+ ...(parentObservationId && { parentObservationId }),
162
+ type,
163
+ ...(startOptions.name && { name: startOptions.name }),
164
+ startTime,
165
+ endTime: new Date().toISOString(),
166
+ level: endOptions.level ?? "default",
167
+ ...(model && { model }),
168
+ ...(modelParameters && { modelParameters }),
169
+ ...(startOptions.input !== undefined && { input: startOptions.input }),
170
+ ...(endOptions.output !== undefined && { output: endOptions.output }),
171
+ ...(endOptions.statusMessage && { statusMessage: endOptions.statusMessage }),
172
+ ...(endOptions.usageDetails && { usageDetails: endOptions.usageDetails }),
173
+ ...(endOptions.costDetails && { costDetails: endOptions.costDetails }),
174
+ metadata: { ...startOptions.metadata, ...endOptions.metadata }
175
+ }
176
+ });
177
+ },
178
+ score: (scoreOptions: ScoreOptions) => enqueueScore(traceId, id, scoreOptions)
179
+ };
180
+ }
181
+
182
+ const fetchImpl = options.fetchImpl ?? fetch;
183
+ const host = options.host.replace(/\/$/, "");
184
+
185
+ return {
186
+ async uploadMedia(uploadOptions: UploadMediaOptions): Promise<UploadedMedia> {
187
+ const body =
188
+ uploadOptions.data instanceof ArrayBuffer
189
+ ? new Uint8Array(uploadOptions.data)
190
+ : uploadOptions.data;
191
+ const res = await fetchImpl(`${host}/api/v1/media`, {
192
+ method: "POST",
193
+ headers: {
194
+ authorization: `Bearer ${options.apiKey}`,
195
+ "content-type": uploadOptions.contentType
196
+ },
197
+ // DOM's BodyInit type isn't in the SDK's lib set; Uint8Array is a
198
+ // valid fetch body in Node 18+ and browsers alike.
199
+ body: body as unknown as NonNullable<RequestInit["body"]>
200
+ });
201
+ if (!res.ok) {
202
+ const text = await res.text().catch(() => "");
203
+ throw new Error(`media upload failed: ${res.status}${text ? ` ${text}` : ""}`);
204
+ }
205
+ return (await res.json()) as UploadedMedia;
206
+ },
207
+
208
+ trace(startOptions: StartTraceOptions = {}) {
209
+ const id = startOptions.id ?? ulid();
210
+ // Fixed at trace-start time and reused by update() below — the trace's
211
+ // timestamp must not drift to "whenever update() happened to be
212
+ // called" (e.g. re-stamping it at completion time would corrupt the
213
+ // recorded start time on every partial update).
214
+ const timestamp = new Date().toISOString();
215
+ batcher.enqueue({
216
+ type: "trace-upsert",
217
+ body: {
218
+ id,
219
+ timestamp,
220
+ ...(startOptions.name && { name: startOptions.name }),
221
+ ...(startOptions.userId && { userId: startOptions.userId }),
222
+ ...(startOptions.sessionId && { sessionId: startOptions.sessionId }),
223
+ ...(startOptions.environment && { environment: startOptions.environment }),
224
+ ...(startOptions.release && { release: startOptions.release }),
225
+ ...(startOptions.version && { version: startOptions.version }),
226
+ tags: startOptions.tags ?? [],
227
+ metadata: startOptions.metadata ?? {},
228
+ ...(startOptions.input !== undefined && { input: startOptions.input })
229
+ }
230
+ });
231
+
232
+ return {
233
+ id,
234
+ span: (options: StartSpanOptions = {}) =>
235
+ makeObservationHandle(ulid(), id, "span", undefined, options),
236
+ generation: (options: StartGenerationOptions = {}) =>
237
+ makeObservationHandle(ulid(), id, "generation", undefined, options),
238
+ update(updateOptions: UpdateTraceOptions) {
239
+ // A trace-upsert replaces the whole row (ClickHouse
240
+ // ReplacingMergeTree has no field-level merge) — every field set
241
+ // by trace() must be carried forward here too, or update() would
242
+ // silently wipe name/userId/sessionId/input back to absent.
243
+ batcher.enqueue({
244
+ type: "trace-upsert",
245
+ body: {
246
+ id,
247
+ timestamp,
248
+ ...(startOptions.name && { name: startOptions.name }),
249
+ ...(startOptions.userId && { userId: startOptions.userId }),
250
+ ...(startOptions.sessionId && { sessionId: startOptions.sessionId }),
251
+ ...(startOptions.environment && { environment: startOptions.environment }),
252
+ ...(startOptions.release && { release: startOptions.release }),
253
+ ...(startOptions.version && { version: startOptions.version }),
254
+ tags: startOptions.tags ?? [],
255
+ ...(startOptions.input !== undefined && { input: startOptions.input }),
256
+ ...(updateOptions.output !== undefined && { output: updateOptions.output }),
257
+ metadata: { ...startOptions.metadata, ...updateOptions.metadata }
258
+ }
259
+ });
260
+ },
261
+ score: (scoreOptions: ScoreOptions) => enqueueScore(id, undefined, scoreOptions)
262
+ };
263
+ },
264
+ flush: () => batcher.flush(),
265
+ shutdown: () => batcher.close()
266
+ };
267
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ export { init } from "./client.js";
2
+ export type {
3
+ IronsideClient,
4
+ IronsideClientOptions,
5
+ TraceHandle,
6
+ ObservationHandle,
7
+ UploadMediaOptions,
8
+ UploadedMedia
9
+ } from "./client.js";
10
+ export type {
11
+ StartTraceOptions,
12
+ UpdateTraceOptions,
13
+ StartSpanOptions,
14
+ StartGenerationOptions,
15
+ EndObservationOptions,
16
+ ScoreOptions
17
+ } from "./types.js";
18
+
19
+ export { wrapOpenAI } from "./wrappers/openai.js";
20
+ export type { WrapOpenAIOptions } from "./wrappers/openai.js";
21
+ export { wrapAnthropic } from "./wrappers/anthropic.js";
22
+ export type { WrapAnthropicOptions } from "./wrappers/anthropic.js";
23
+ export { recordGenerateTextResult } from "./wrappers/vercel-ai.js";
24
+ export type { RecordGenerateTextOptions } from "./wrappers/vercel-ai.js";
package/src/types.ts ADDED
@@ -0,0 +1,75 @@
1
+ // Wire-contract types, deliberately duplicated from (not imported from)
2
+ // @ironside/shared: this package ships standalone to npm and cannot depend
3
+ // on a private workspace package. Kept intentionally minimal — only the
4
+ // fields the SDK itself constructs.
5
+
6
+ export interface StartTraceOptions {
7
+ id?: string;
8
+ name?: string;
9
+ userId?: string;
10
+ sessionId?: string;
11
+ environment?: string;
12
+ release?: string;
13
+ version?: string;
14
+ tags?: string[];
15
+ metadata?: Record<string, string>;
16
+ input?: unknown;
17
+ }
18
+
19
+ export interface UpdateTraceOptions {
20
+ output?: unknown;
21
+ metadata?: Record<string, string>;
22
+ }
23
+
24
+ interface ScoreOptionsBase {
25
+ id?: string;
26
+ /** Attaches the score to a specific observation instead of the trace as a whole. */
27
+ observationId?: string;
28
+ name: string;
29
+ source?: "api" | "eval" | "annotation";
30
+ comment?: string;
31
+ metadata?: Record<string, string>;
32
+ }
33
+
34
+ /**
35
+ * Exactly one of value/stringValue — enforced at the type level (not just
36
+ * documented) so a caller can't produce a score with neither (dropped
37
+ * server-side against the domain schema's invariant, silently, since the
38
+ * SDK's ingest is fire-and-forget) or both (an internally inconsistent
39
+ * dataType/payload pairing).
40
+ */
41
+ export type ScoreOptions =
42
+ | (ScoreOptionsBase & { value: number; stringValue?: undefined })
43
+ | (ScoreOptionsBase & { stringValue: string; value?: undefined });
44
+
45
+ export interface StartSpanOptions {
46
+ id?: string;
47
+ name?: string;
48
+ input?: unknown;
49
+ metadata?: Record<string, string>;
50
+ }
51
+
52
+ export interface StartGenerationOptions extends StartSpanOptions {
53
+ model?: string;
54
+ modelParameters?: Record<string, string | number | boolean | null>;
55
+ }
56
+
57
+ export interface EndObservationOptions {
58
+ output?: unknown;
59
+ statusMessage?: string;
60
+ level?: "debug" | "default" | "warning" | "error";
61
+ usageDetails?: Record<string, number>;
62
+ costDetails?: Record<string, number>;
63
+ metadata?: Record<string, string>;
64
+ }
65
+
66
+ export type IngestEventType =
67
+ | "trace-upsert"
68
+ | "observation-upsert"
69
+ | "score-upsert";
70
+
71
+ export interface IngestRequestEvent {
72
+ id?: string;
73
+ type: IngestEventType;
74
+ body: unknown;
75
+ }