@struktur/sdk 2.0.0 → 2.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@struktur/sdk",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "license": "FSL-1.1-MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -20,7 +20,7 @@
20
20
  "@ai-sdk/google": "^3.0.0",
21
21
  "@ai-sdk/openai": "^3.0.0",
22
22
  "@openrouter/ai-sdk-provider": "^2.0.0",
23
- "@struktur/agent-strategy": "1.0.0",
23
+ "@struktur/agent-strategy": "2.1.0",
24
24
  "ai": "^6.0.97",
25
25
  "ajv": "^8.17.1",
26
26
  "ajv-formats": "^3.0.1",
@@ -3,11 +3,24 @@ import os from "node:os";
3
3
  import { chmod, mkdir } from "node:fs/promises";
4
4
  import type { ParserDef, ParsersConfig } from "@struktur/sdk";
5
5
 
6
+ type TelemetryConfig = {
7
+ enabled: boolean;
8
+ provider: string;
9
+ url?: string;
10
+ apiKey?: string;
11
+ projectName?: string;
12
+ publicKey?: string; // For Langfuse
13
+ secretKey?: string; // For Langfuse
14
+ baseUrl?: string; // For Langfuse
15
+ sampleRate?: number;
16
+ };
17
+
6
18
  type ConfigStore = {
7
19
  version: 1;
8
20
  defaultModel?: string;
9
21
  aliases?: Record<string, string>;
10
22
  parsers?: ParsersConfig;
23
+ telemetry?: TelemetryConfig;
11
24
  };
12
25
 
13
26
  const CONFIG_DIR_ENV = "STRUKTUR_CONFIG_DIR";
@@ -127,3 +140,47 @@ export const deleteParser = async (mimeType: string): Promise<boolean> => {
127
140
  await writeConfigStore(store);
128
141
  return true;
129
142
  };
143
+
144
+ // --- Telemetry config management ---
145
+
146
+ export const getTelemetryConfig = async (): Promise<TelemetryConfig | undefined> => {
147
+ const store = await readConfigStore();
148
+ return store.telemetry;
149
+ };
150
+
151
+ export const setTelemetryConfig = async (config: TelemetryConfig): Promise<void> => {
152
+ const store = await readConfigStore();
153
+ store.telemetry = config;
154
+ await writeConfigStore(store);
155
+ };
156
+
157
+ export const enableTelemetry = async (
158
+ provider: string,
159
+ options: Omit<TelemetryConfig, "enabled" | "provider">
160
+ ): Promise<void> => {
161
+ const store = await readConfigStore();
162
+ store.telemetry = {
163
+ enabled: true,
164
+ provider,
165
+ ...options,
166
+ };
167
+ await writeConfigStore(store);
168
+ };
169
+
170
+ export const disableTelemetry = async (): Promise<void> => {
171
+ const store = await readConfigStore();
172
+ if (store.telemetry) {
173
+ store.telemetry.enabled = false;
174
+ }
175
+ await writeConfigStore(store);
176
+ };
177
+
178
+ export const deleteTelemetryConfig = async (): Promise<boolean> => {
179
+ const store = await readConfigStore();
180
+ if (!store.telemetry) {
181
+ return false;
182
+ }
183
+ delete store.telemetry;
184
+ await writeConfigStore(store);
185
+ return true;
186
+ };
package/src/extract.ts CHANGED
@@ -34,29 +34,52 @@ export const extract = async <T>(
34
34
  options: ExtractionOptions<T>,
35
35
  ): Promise<ExtractionResult<T>> => {
36
36
  const debug = options.debug;
37
+ const telemetry = options.telemetry;
37
38
 
38
- // Validate mutual exclusion and resolve the concrete schema early so that
39
- // every strategy receives a fully-populated options object.
40
- let resolvedOptions: ExtractionOptions<T>;
41
- try {
42
- const schema = resolveSchema(options);
43
- resolvedOptions = { ...options, schema };
44
- } catch (error) {
45
- debug?.extractionComplete({
46
- success: false,
47
- totalInputTokens: 0,
48
- totalOutputTokens: 0,
49
- totalTokens: 0,
50
- error: (error as Error).message,
51
- });
52
- return {
53
- data: null as unknown as T,
54
- usage: emptyUsage,
55
- error: error as Error,
56
- };
39
+ // Initialize telemetry if provided
40
+ if (telemetry) {
41
+ await telemetry.initialize();
57
42
  }
58
43
 
44
+ // Start root extraction span
45
+ const rootSpan = telemetry?.startSpan({
46
+ name: "struktur.extract",
47
+ kind: "CHAIN",
48
+ attributes: {
49
+ "extraction.strategy": options.strategy?.name ?? "default",
50
+ "extraction.artifacts.count": options.artifacts.length,
51
+ },
52
+ });
53
+
59
54
  try {
55
+ // Validate mutual exclusion and resolve the concrete schema early so that
56
+ // every strategy receives a fully-populated options object.
57
+ let resolvedOptions: ExtractionOptions<T>;
58
+ try {
59
+ const schema = resolveSchema(options);
60
+ resolvedOptions = { ...options, schema };
61
+ } catch (error) {
62
+ debug?.extractionComplete({
63
+ success: false,
64
+ totalInputTokens: 0,
65
+ totalOutputTokens: 0,
66
+ totalTokens: 0,
67
+ error: (error as Error).message,
68
+ });
69
+
70
+ telemetry?.endSpan(rootSpan!, {
71
+ status: "error",
72
+ error: error as Error,
73
+ });
74
+ await telemetry?.shutdown();
75
+
76
+ return {
77
+ data: null as unknown as T,
78
+ usage: emptyUsage,
79
+ error: error as Error,
80
+ };
81
+ }
82
+
60
83
  const total = resolvedOptions.strategy.getEstimatedSteps?.(resolvedOptions.artifacts);
61
84
 
62
85
  debug?.strategyRunStart({
@@ -95,6 +118,13 @@ export const extract = async <T>(
95
118
  error: result.error?.message,
96
119
  });
97
120
 
121
+ telemetry?.endSpan(rootSpan!, {
122
+ status: result.error ? "error" : "ok",
123
+ output: result.data,
124
+ error: result.error,
125
+ });
126
+ await telemetry?.shutdown();
127
+
98
128
  return result;
99
129
  } catch (error) {
100
130
  debug?.extractionComplete({
@@ -105,6 +135,12 @@ export const extract = async <T>(
105
135
  error: (error as Error).message,
106
136
  });
107
137
 
138
+ telemetry?.endSpan(rootSpan!, {
139
+ status: "error",
140
+ error: error as Error,
141
+ });
142
+ await telemetry?.shutdown();
143
+
108
144
  return {
109
145
  data: null as unknown as T,
110
146
  usage: emptyUsage,
package/src/index.ts CHANGED
@@ -10,6 +10,14 @@ export type {
10
10
  Usage,
11
11
  AnyJSONSchema,
12
12
  TypedJSONSchema,
13
+ // Agent event types
14
+ AgentEvents,
15
+ AgentToolStartInfo,
16
+ AgentToolEndInfo,
17
+ AgentMessageInfo,
18
+ AgentReasoningInfo,
19
+ // Telemetry
20
+ TelemetryAdapter,
13
21
  } from "./types";
14
22
 
15
23
  export { extract } from "./extract";
@@ -83,6 +91,11 @@ export {
83
91
  getParser,
84
92
  setParser,
85
93
  deleteParser,
94
+ getTelemetryConfig,
95
+ setTelemetryConfig,
96
+ enableTelemetry,
97
+ disableTelemetry,
98
+ deleteTelemetryConfig,
86
99
  } from "./auth/config";
87
100
  export {
88
101
  listStoredProviders,
@@ -1,5 +1,5 @@
1
1
  import { generateText, Output, jsonSchema, type ModelMessage } from "ai";
2
- import type { AnyJSONSchema, Usage } from "../types";
2
+ import type { AnyJSONSchema, Usage, TelemetryAdapter } from "../types";
3
3
  import type { UserContent } from "./message";
4
4
 
5
5
  type GenerateTextParams = Parameters<typeof generateText>[0];
@@ -15,6 +15,14 @@ export type StructuredRequest<T> = {
15
15
  schemaName?: string;
16
16
  schemaDescription?: string;
17
17
  strict?: boolean;
18
+ /**
19
+ * Telemetry adapter for tracing LLM calls
20
+ */
21
+ telemetry?: TelemetryAdapter;
22
+ /**
23
+ * Parent span for creating hierarchical traces
24
+ */
25
+ parentSpan?: { id: string; traceId: string; name: string; kind: string; startTime: number; parentId?: string };
18
26
  };
19
27
 
20
28
  export type StructuredResponse<T> = {
@@ -36,6 +44,21 @@ const isZodSchema = (
36
44
  export const generateStructured = async <T>(
37
45
  request: StructuredRequest<T>,
38
46
  ): Promise<StructuredResponse<T>> => {
47
+ const { telemetry, parentSpan } = request;
48
+
49
+ // Start LLM span if telemetry is enabled
50
+ const llmSpan = telemetry?.startSpan({
51
+ name: "llm.generateStructured",
52
+ kind: "LLM",
53
+ parentSpan,
54
+ attributes: {
55
+ "llm.schema_name": request.schemaName ?? "extract",
56
+ "llm.strict": request.strict ?? false,
57
+ },
58
+ });
59
+
60
+ const startTime = Date.now();
61
+
39
62
  const schema = isZodSchema(request.schema)
40
63
  ? request.schema
41
64
  : jsonSchema(request.schema as AnyJSONSchema);
@@ -84,6 +107,13 @@ export const generateStructured = async <T>(
84
107
  ...(providerOptions ? { providerOptions } : {}),
85
108
  });
86
109
  } catch (error) {
110
+ // Determine model ID for error messages
111
+ const modelId =
112
+ typeof request.model === "object" && request.model !== null
113
+ ? (request.model as { modelId?: string }).modelId ??
114
+ JSON.stringify(request.model)
115
+ : String(request.model);
116
+
87
117
  if (
88
118
  error &&
89
119
  typeof error === "object" &&
@@ -101,12 +131,6 @@ export const generateStructured = async <T>(
101
131
  };
102
132
  };
103
133
 
104
- const modelId =
105
- typeof request.model === "object" && request.model !== null
106
- ? (request.model as { modelId?: string }).modelId ??
107
- JSON.stringify(request.model)
108
- : String(request.model);
109
-
110
134
  const responseBody = apiError.responseBody;
111
135
  const errorData = apiError.data;
112
136
 
@@ -156,6 +180,30 @@ export const generateStructured = async <T>(
156
180
  );
157
181
  }
158
182
  }
183
+
184
+ // Record error in telemetry
185
+ if (llmSpan && telemetry) {
186
+ const latencyMs = Date.now() - startTime;
187
+ telemetry.recordEvent(llmSpan, {
188
+ type: "llm_call",
189
+ model: modelId,
190
+ provider: "unknown", // Will be determined by the model
191
+ input: {
192
+ messages: request.messages ?? [{ role: "user", content: typeof request.user === "string" ? request.user : "" }],
193
+ temperature: undefined,
194
+ maxTokens: undefined,
195
+ schema: request.schema,
196
+ },
197
+ error: error instanceof Error ? error : new Error(String(error)),
198
+ latencyMs,
199
+ });
200
+ telemetry.endSpan(llmSpan, {
201
+ status: "error",
202
+ error: error instanceof Error ? error : new Error(String(error)),
203
+ latencyMs,
204
+ });
205
+ }
206
+
159
207
  throw error;
160
208
  }
161
209
 
@@ -179,5 +227,38 @@ export const generateStructured = async <T>(
179
227
  totalTokens,
180
228
  };
181
229
 
230
+ // Record successful LLM call in telemetry
231
+ if (llmSpan && telemetry) {
232
+ const latencyMs = Date.now() - startTime;
233
+ telemetry.recordEvent(llmSpan, {
234
+ type: "llm_call",
235
+ model: typeof request.model === "object" && request.model !== null
236
+ ? (request.model as { modelId?: string }).modelId ?? "unknown"
237
+ : String(request.model),
238
+ provider: preferredProvider ?? "unknown",
239
+ input: {
240
+ messages: request.messages ?? [{ role: "user", content: typeof request.user === "string" ? request.user : "" }],
241
+ temperature: undefined,
242
+ maxTokens: undefined,
243
+ schema: request.schema,
244
+ },
245
+ output: {
246
+ content: JSON.stringify(result.output),
247
+ structured: true,
248
+ usage: {
249
+ input: inputTokens,
250
+ output: outputTokens,
251
+ total: totalTokens,
252
+ },
253
+ },
254
+ latencyMs,
255
+ });
256
+ telemetry.endSpan(llmSpan, {
257
+ status: "ok",
258
+ output: result.output,
259
+ latencyMs,
260
+ });
261
+ }
262
+
182
263
  return { data: result.output as T, usage };
183
264
  };
@@ -5,7 +5,7 @@ import {
5
5
  validateAllowingMissingRequired,
6
6
  } from "../validation/validator";
7
7
  import type { ModelMessage } from "ai";
8
- import type { ExtractionEvents, Usage } from "../types";
8
+ import type { ExtractionEvents, Usage, TelemetryAdapter } from "../types";
9
9
  import type { DebugLogger } from "../debug/logger";
10
10
  import { generateStructured } from "./LLMClient";
11
11
  import type { UserContent } from "./message";
@@ -22,9 +22,30 @@ export type RetryOptions<T> = {
22
22
  strict?: boolean;
23
23
  debug?: DebugLogger;
24
24
  callId?: string;
25
+ /**
26
+ * Telemetry adapter for tracing validation and retries
27
+ */
28
+ telemetry?: TelemetryAdapter;
29
+ /**
30
+ * Parent span for creating hierarchical traces
31
+ */
32
+ parentSpan?: { id: string; traceId: string; name: string; kind: string; startTime: number; parentId?: string };
25
33
  };
26
34
 
27
35
  export const runWithRetries = async <T>(options: RetryOptions<T>) => {
36
+ const { telemetry, parentSpan } = options;
37
+
38
+ // Start validation/retry span if telemetry is enabled
39
+ const retrySpan = telemetry?.startSpan({
40
+ name: "struktur.validation_retry",
41
+ kind: "CHAIN",
42
+ parentSpan,
43
+ attributes: {
44
+ "retry.max_attempts": options.maxAttempts ?? 3,
45
+ "retry.schema_name": options.schemaName ?? "extract",
46
+ },
47
+ });
48
+
28
49
  const ajv = createAjv();
29
50
  const maxAttempts = options.maxAttempts ?? 3;
30
51
  const messages: ModelMessage[] = [{ role: "user", content: options.user }];
@@ -76,6 +97,8 @@ export const runWithRetries = async <T>(options: RetryOptions<T>) => {
76
97
  user: options.user,
77
98
  messages,
78
99
  strict: options.strict,
100
+ telemetry,
101
+ parentSpan: retrySpan,
79
102
  });
80
103
  const durationMs = Date.now() - startTime;
81
104
 
@@ -105,6 +128,24 @@ export const runWithRetries = async <T>(options: RetryOptions<T>) => {
105
128
  durationMs,
106
129
  });
107
130
 
131
+ // Record successful validation
132
+ if (retrySpan && telemetry) {
133
+ telemetry.recordEvent(retrySpan, {
134
+ type: "validation",
135
+ attempt,
136
+ maxAttempts,
137
+ schema: options.schema,
138
+ input: result.data,
139
+ success: true,
140
+ latencyMs: durationMs,
141
+ });
142
+ telemetry.endSpan(retrySpan, {
143
+ status: "ok",
144
+ output: validated,
145
+ latencyMs: durationMs,
146
+ });
147
+ }
148
+
108
149
  return { data: validated, usage };
109
150
  } else {
110
151
  const validationResult = validateAllowingMissingRequired<T>(
@@ -125,6 +166,24 @@ export const runWithRetries = async <T>(options: RetryOptions<T>) => {
125
166
  durationMs,
126
167
  });
127
168
 
169
+ // Record successful validation
170
+ if (retrySpan && telemetry) {
171
+ telemetry.recordEvent(retrySpan, {
172
+ type: "validation",
173
+ attempt,
174
+ maxAttempts,
175
+ schema: options.schema,
176
+ input: result.data,
177
+ success: true,
178
+ latencyMs: durationMs,
179
+ });
180
+ telemetry.endSpan(retrySpan, {
181
+ status: "ok",
182
+ output: validationResult.data,
183
+ latencyMs: durationMs,
184
+ });
185
+ }
186
+
128
187
  return { data: validationResult.data, usage };
129
188
  }
130
189
 
@@ -143,6 +202,20 @@ export const runWithRetries = async <T>(options: RetryOptions<T>) => {
143
202
  errors: error.errors,
144
203
  });
145
204
 
205
+ // Record failed validation
206
+ if (retrySpan && telemetry) {
207
+ telemetry.recordEvent(retrySpan, {
208
+ type: "validation",
209
+ attempt,
210
+ maxAttempts,
211
+ schema: options.schema,
212
+ input: result.data,
213
+ success: false,
214
+ errors: error.errors,
215
+ latencyMs: durationMs,
216
+ });
217
+ }
218
+
146
219
  // Emit retry event before attempting retry
147
220
  const nextAttempt = attempt + 1;
148
221
  if (nextAttempt <= maxAttempts) {
@@ -180,6 +253,15 @@ export const runWithRetries = async <T>(options: RetryOptions<T>) => {
180
253
  error: (error as Error).message,
181
254
  });
182
255
 
256
+ // Record error in telemetry
257
+ if (retrySpan && telemetry) {
258
+ telemetry.endSpan(retrySpan, {
259
+ status: "error",
260
+ error: error as Error,
261
+ latencyMs: durationMs,
262
+ });
263
+ }
264
+
183
265
  break;
184
266
  }
185
267
  }
@@ -84,6 +84,20 @@ export class DoublePassAutoMergeStrategy<T> implements ExtractionStrategy<T> {
84
84
 
85
85
  async run(options: ExtractionOptions<T>): Promise<ExtractionResult<T>> {
86
86
  const debug = options.debug;
87
+ const { telemetry } = options;
88
+
89
+ // Create strategy-level span
90
+ const strategySpan = telemetry?.startSpan({
91
+ name: "strategy.double-pass-auto-merge",
92
+ kind: "CHAIN",
93
+ attributes: {
94
+ "strategy.name": this.name,
95
+ "strategy.artifacts.count": options.artifacts.length,
96
+ "strategy.chunk_size": this.config.chunkSize,
97
+ "strategy.concurrency": this.config.concurrency,
98
+ },
99
+ });
100
+
87
101
  const batches = getBatches(
88
102
  options.artifacts,
89
103
  {
@@ -91,11 +105,24 @@ export class DoublePassAutoMergeStrategy<T> implements ExtractionStrategy<T> {
91
105
  maxImages: this.config.maxImages,
92
106
  },
93
107
  debug,
108
+ telemetry ?? undefined,
109
+ strategySpan,
94
110
  );
95
111
 
96
112
  const schema = serializeSchema(options.schema);
97
113
  const totalSteps = this.getEstimatedSteps(options.artifacts);
98
114
  let step = 1;
115
+
116
+ // Create pass 1 span
117
+ const pass1Span = telemetry?.startSpan({
118
+ name: "struktur.pass_1",
119
+ kind: "CHAIN",
120
+ parentSpan: strategySpan,
121
+ attributes: {
122
+ "pass.number": 1,
123
+ "pass.type": "parallel_extraction",
124
+ },
125
+ });
99
126
 
100
127
  const tasks = batches.map((batch, index) => async () => {
101
128
  const prompt = buildExtractorPrompt(
@@ -114,6 +141,8 @@ export class DoublePassAutoMergeStrategy<T> implements ExtractionStrategy<T> {
114
141
  strict: options.strict ?? this.config.strict,
115
142
  debug,
116
143
  callId: `double_pass_auto_1_batch_${index + 1}`,
144
+ telemetry: telemetry ?? undefined,
145
+ parentSpan: pass1Span,
117
146
  });
118
147
  step += 1;
119
148
  await options.events?.onStep?.({
@@ -145,6 +174,17 @@ export class DoublePassAutoMergeStrategy<T> implements ExtractionStrategy<T> {
145
174
  inputCount: results.length,
146
175
  strategy: this.name,
147
176
  });
177
+
178
+ // Create smart merge span
179
+ const mergeSpan = telemetry?.startSpan({
180
+ name: "struktur.smart_merge",
181
+ kind: "CHAIN",
182
+ parentSpan: pass1Span,
183
+ attributes: {
184
+ "merge.strategy": "smart",
185
+ "merge.input_count": results.length,
186
+ },
187
+ });
148
188
 
149
189
  for (let i = 0; i < results.length; i++) {
150
190
  const result = results[i]!;
@@ -168,12 +208,54 @@ export class DoublePassAutoMergeStrategy<T> implements ExtractionStrategy<T> {
168
208
  leftCount: leftArray,
169
209
  rightCount: rightArray,
170
210
  });
211
+
212
+ // Record merge event in telemetry
213
+ if (mergeSpan && telemetry) {
214
+ telemetry.recordEvent(mergeSpan, {
215
+ type: "merge",
216
+ strategy: "smart",
217
+ inputCount: rightArray ?? 1,
218
+ outputCount: leftArray ?? 1,
219
+ });
220
+ }
171
221
  }
172
222
  }
173
223
 
174
224
  debug?.mergeComplete({ mergeId: "double_pass_auto_merge", success: true });
225
+
226
+ // End merge span
227
+ if (mergeSpan && telemetry) {
228
+ telemetry.endSpan(mergeSpan, {
229
+ status: "ok",
230
+ output: merged,
231
+ });
232
+ }
175
233
 
176
234
  merged = dedupeArrays(merged);
235
+
236
+ // Create exact dedupe span
237
+ const exactDedupeSpan = telemetry?.startSpan({
238
+ name: "struktur.exact_dedupe",
239
+ kind: "CHAIN",
240
+ parentSpan: pass1Span,
241
+ attributes: {
242
+ "dedupe.method": "exact_hashing",
243
+ },
244
+ });
245
+
246
+ // End exact dedupe span
247
+ if (exactDedupeSpan && telemetry) {
248
+ telemetry.recordEvent(exactDedupeSpan, {
249
+ type: "merge",
250
+ strategy: "exact_hash_dedupe",
251
+ inputCount: Object.keys(merged).length,
252
+ outputCount: Object.keys(merged).length,
253
+ });
254
+ telemetry.endSpan(exactDedupeSpan, {
255
+ status: "ok",
256
+ output: merged,
257
+ });
258
+ }
177
259
 
178
260
  const dedupePrompt = buildDeduplicationPrompt(schema, merged);
179
261
 
@@ -181,6 +263,16 @@ export class DoublePassAutoMergeStrategy<T> implements ExtractionStrategy<T> {
181
263
  dedupeId: "double_pass_auto_dedupe",
182
264
  itemCount: Object.keys(merged).length,
183
265
  });
266
+
267
+ // Create LLM dedupe span
268
+ const llmDedupeSpan = telemetry?.startSpan({
269
+ name: "struktur.llm_dedupe",
270
+ kind: "CHAIN",
271
+ parentSpan: pass1Span,
272
+ attributes: {
273
+ "dedupe.method": "llm",
274
+ },
275
+ });
184
276
 
185
277
  const dedupeResponse = await runWithRetries<{ keys: string[] }>({
186
278
  model: this.config.dedupeModel ?? this.config.model,
@@ -192,6 +284,8 @@ export class DoublePassAutoMergeStrategy<T> implements ExtractionStrategy<T> {
192
284
  strict: this.config.strict,
193
285
  debug,
194
286
  callId: "double_pass_auto_dedupe",
287
+ telemetry: telemetry ?? undefined,
288
+ parentSpan: llmDedupeSpan,
195
289
  });
196
290
 
197
291
  step += 1;
@@ -217,9 +311,41 @@ export class DoublePassAutoMergeStrategy<T> implements ExtractionStrategy<T> {
217
311
  duplicatesFound: dedupeResponse.data.keys.length,
218
312
  itemsRemoved: dedupeResponse.data.keys.length,
219
313
  });
314
+
315
+ // End LLM dedupe span
316
+ if (llmDedupeSpan && telemetry) {
317
+ telemetry.recordEvent(llmDedupeSpan, {
318
+ type: "merge",
319
+ strategy: "llm_dedupe",
320
+ inputCount: Object.keys(merged).length,
321
+ outputCount: Object.keys(deduped).length,
322
+ deduped: dedupeResponse.data.keys.length,
323
+ });
324
+ telemetry.endSpan(llmDedupeSpan, {
325
+ status: "ok",
326
+ output: deduped,
327
+ });
328
+ }
329
+
330
+ // End pass 1 span
331
+ telemetry?.endSpan(pass1Span!, {
332
+ status: "ok",
333
+ output: deduped,
334
+ });
220
335
 
221
336
  let currentData = deduped as T;
222
337
  const usages = [...results.map((r) => r.usage), dedupeResponse.usage];
338
+
339
+ // Create pass 2 span
340
+ const pass2Span = telemetry?.startSpan({
341
+ name: "struktur.pass_2",
342
+ kind: "CHAIN",
343
+ parentSpan: strategySpan,
344
+ attributes: {
345
+ "pass.number": 2,
346
+ "pass.type": "sequential_refinement",
347
+ },
348
+ });
223
349
 
224
350
  for (const [index, batch] of batches.entries()) {
225
351
  const prompt = buildSequentialPrompt(
@@ -240,6 +366,8 @@ export class DoublePassAutoMergeStrategy<T> implements ExtractionStrategy<T> {
240
366
  strict: this.config.strict,
241
367
  debug,
242
368
  callId: `double_pass_auto_2_batch_${index + 1}`,
369
+ telemetry: telemetry ?? undefined,
370
+ parentSpan: pass2Span,
243
371
  });
244
372
 
245
373
  currentData = result.data;
@@ -258,6 +386,18 @@ export class DoublePassAutoMergeStrategy<T> implements ExtractionStrategy<T> {
258
386
  strategy: this.name,
259
387
  });
260
388
  }
389
+
390
+ // End pass 2 span
391
+ telemetry?.endSpan(pass2Span!, {
392
+ status: "ok",
393
+ output: currentData,
394
+ });
395
+
396
+ // End strategy span
397
+ telemetry?.endSpan(strategySpan!, {
398
+ status: "ok",
399
+ output: currentData,
400
+ });
261
401
 
262
402
  return { data: currentData, usage: mergeUsage(usages) };
263
403
  }