@vzn/vx-otel 0.0.0 → 0.0.485

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/otlp.ts ADDED
@@ -0,0 +1,482 @@
1
+ // OTLP/HTTP JSON payload builders — pure functions, no SDK.
2
+ //
3
+ // vx-otel speaks the OTLP/HTTP JSON protocol directly rather than pulling the
4
+ // OpenTelemetry SDK closure. The protocol is a stable wire spec; building the
5
+ // payloads by hand keeps the package zero-dependency, fully testable, and free
6
+ // of SDK-version drift. Maps a vx run to:
7
+ // - a TRACE: one root `vx.run` span + one child `vx.task` span per task,
8
+ // with CI/CD + VCS semantic-convention attributes;
9
+ // - METRICS: task/run counters + a run-duration gauge;
10
+ // - LOGS: one record per executed task carrying its captured output tail.
11
+ //
12
+ // References: OpenTelemetry CI/CD + VCS semantic conventions; OTLP/JSON
13
+ // protobuf-JSON mapping (int64 fields are decimal STRINGS).
14
+
15
+ import { TELEMETRY_SCHEMA_VERSION } from '@vzn/vx'
16
+ import type { RunContextRecord, RunSummaryRecord, TaskLogEntry, TaskTelemetry } from '@vzn/vx'
17
+
18
+ // --- OTLP value + attribute primitives ---------------------------------
19
+
20
+ type AnyValue =
21
+ | { stringValue: string }
22
+ | { intValue: string }
23
+ | { boolValue: boolean }
24
+ | { doubleValue: number }
25
+
26
+ export interface KeyValue {
27
+ key: string
28
+ value: AnyValue
29
+ }
30
+
31
+ function strAttr(key: string, v: string): KeyValue {
32
+ return { key, value: { stringValue: v } }
33
+ }
34
+ function intAttr(key: string, v: number): KeyValue {
35
+ return { key, value: { intValue: String(Math.trunc(v)) } }
36
+ }
37
+ function boolAttr(key: string, v: boolean): KeyValue {
38
+ return { key, value: { boolValue: v } }
39
+ }
40
+ /** An int64 that is ALREADY a decimal string — OTLP's own encoding for the
41
+ * type. Passing it through untouched is the point: routing a nanosecond
42
+ * count through a JS number would round it. */
43
+ function int64Attr(key: string, v: string): KeyValue {
44
+ return { key, value: { intValue: v } }
45
+ }
46
+
47
+ // --- semantic-convention keys ------------------------------------------
48
+
49
+ const SEMCONV = {
50
+ pipelineRunId: 'cicd.pipeline.run.id',
51
+ pipelineResult: 'cicd.pipeline.result',
52
+ taskName: 'cicd.pipeline.task.name',
53
+ taskRunResult: 'cicd.pipeline.task.run.result',
54
+ vcsHeadRevision: 'vcs.ref.head.revision',
55
+ vcsHeadName: 'vcs.ref.head.name',
56
+ serviceName: 'service.name',
57
+ serviceVersion: 'service.version',
58
+ } as const
59
+
60
+ /**
61
+ * Every vx-specific attribute key, in ONE place.
62
+ *
63
+ * These keys are the wire: a trace is only a lossless description of a run if
64
+ * a reader can find every field again, and the only thing standing between an
65
+ * encoder and a decoder is agreement on these strings. Naming them once means
66
+ * a rename is a compile error here rather than a field that silently stops
67
+ * arriving. Anything with a real OTel semantic convention uses it instead (see
68
+ * SEMCONV above) — these cover what the conventions have no word for.
69
+ *
70
+ * `vx.default_branch` deliberately does NOT reuse `vcs.ref.base.name`: that
71
+ * convention means the base ref of a specific change, which is not the same
72
+ * question as "what is this repo's trunk", and a decoder reading the wrong
73
+ * one would quietly mis-classify every run's trust scope.
74
+ */
75
+ const VX_ATTR = {
76
+ // run
77
+ schema: 'vx.telemetry.schema',
78
+ workspaceId: 'vx.workspace.id',
79
+ workspaceName: 'vx.workspace.name',
80
+ command: 'vx.command',
81
+ requestedTasks: 'vx.requested_tasks',
82
+ cachePolicy: 'vx.cache_policy',
83
+ concurrency: 'vx.concurrency',
84
+ flow: 'vx.flow',
85
+ ci: 'vx.ci',
86
+ ciProvider: 'vx.ci.provider',
87
+ host: 'vx.host',
88
+ os: 'vx.os',
89
+ arch: 'vx.arch',
90
+ version: 'vx.version',
91
+ dirty: 'vx.dirty',
92
+ defaultBranch: 'vx.default_branch',
93
+ tagPrefix: 'vx.tag.',
94
+ // run summary (root span only)
95
+ runStartedAt: 'vx.run.started_at',
96
+ runEndedAt: 'vx.run.ended_at',
97
+ runDurationMs: 'vx.run.duration_ms',
98
+ runTaskCount: 'vx.run.task_count',
99
+ runFailedCount: 'vx.run.failed_count',
100
+ runAbortedCount: 'vx.run.aborted_count',
101
+ runHitCount: 'vx.run.hit_count',
102
+ runHitLocalCount: 'vx.run.hit_local_count',
103
+ runHitRemoteCount: 'vx.run.hit_remote_count',
104
+ runExitOk: 'vx.run.exit_ok',
105
+ // task
106
+ taskProject: 'vx.task.project',
107
+ taskRunStartedAt: 'vx.task.run_started_at',
108
+ taskTask: 'vx.task.task',
109
+ cacheSource: 'vx.cache.source',
110
+ taskExitCode: 'vx.task.exit_code',
111
+ taskStatus: 'vx.task.status',
112
+ taskDurationMs: 'vx.task.duration_ms',
113
+ taskHash: 'vx.task.hash',
114
+ cpuMs: 'vx.cpu_ms',
115
+ taskWhere: 'vx.task.where',
116
+ taskBlockedBy: 'vx.task.blocked_by',
117
+ taskTimedOut: 'vx.task.timed_out',
118
+ taskSandboxViolations: 'vx.task.sandbox_violations',
119
+ taskNotReady: 'vx.task.not_ready',
120
+ taskOutputs: 'vx.task.outputs',
121
+ peakRssBytes: 'vx.peak_rss_bytes',
122
+ taskAttempts: 'vx.task.attempts',
123
+ wallclockStartNs: 'vx.task.wallclock_start_ns',
124
+ wallclockEndNs: 'vx.task.wallclock_end_ns',
125
+ // task log records (the OTel Logs signal)
126
+ logCharsFull: 'vx.log.chars_full',
127
+ logTruncatedHead: 'vx.log.truncated_head',
128
+ logStatus: 'vx.log.status',
129
+ } as const
130
+
131
+ // OTLP status codes: 0 UNSET, 1 OK, 2 ERROR. Span kind: 1 INTERNAL.
132
+ const STATUS_UNSET = 0
133
+ const STATUS_ERROR = 2
134
+ export const SPAN_KIND_INTERNAL = 1
135
+ // Metric aggregation temporality: 1 = DELTA.
136
+ const AGG_DELTA = 1
137
+
138
+ export interface OtlpSpan {
139
+ traceId: string
140
+ spanId: string
141
+ parentSpanId?: string
142
+ name: string
143
+ kind: number
144
+ startTimeUnixNano: string
145
+ endTimeUnixNano: string
146
+ attributes: KeyValue[]
147
+ status: { code: number }
148
+ }
149
+
150
+ // --- attribute mapping (pure) ------------------------------------------
151
+
152
+ /**
153
+ * Resource attributes — service identity, after `OTEL_RESOURCE_ATTRIBUTES`'
154
+ * own (`extra`), whose `service.name` / `service.version` vx's replace.
155
+ * Run/VCS context rides span attrs.
156
+ */
157
+ function resourceAttributes(
158
+ serviceName: string,
159
+ vxVersion: string,
160
+ extra: Readonly<Record<string, string>> = {},
161
+ ): KeyValue[] {
162
+ const own = new Set<string>([SEMCONV.serviceName, SEMCONV.serviceVersion])
163
+ return [
164
+ ...Object.entries(extra)
165
+ .filter(([k]) => !own.has(k))
166
+ .map(([k, v]) => strAttr(k, v)),
167
+ strAttr(SEMCONV.serviceName, serviceName),
168
+ strAttr(SEMCONV.serviceVersion, vxVersion),
169
+ ]
170
+ }
171
+
172
+ /**
173
+ * Attributes for the root `vx.run` span.
174
+ *
175
+ * With `summary` supplied this is a COMPLETE description of the run — every
176
+ * `RunContextRecord` field plus the run-level tallies — so a receiver can
177
+ * rebuild the invocation header from the trace alone. The summary is absent
178
+ * only when a run died before it was assembled; the context half still ships.
179
+ *
180
+ * `started_at` / `ended_at` ride as millisecond attributes even though the
181
+ * span already carries times, because the span's are unix-NANOS: decoding
182
+ * them costs a BigInt (epoch-ms × 1e6 is past Number.MAX_SAFE_INTEGER), and
183
+ * these two values are a storage partition key — silently losing precision on
184
+ * them is not a rounding error, it is a row in the wrong partition.
185
+ */
186
+ export function runSpanAttributes(run: RunContextRecord, summary?: RunSummaryRecord): KeyValue[] {
187
+ const attrs: KeyValue[] = [
188
+ strAttr(SEMCONV.pipelineRunId, run.runId),
189
+ intAttr(VX_ATTR.schema, TELEMETRY_SCHEMA_VERSION),
190
+ strAttr(VX_ATTR.workspaceId, run.workspaceId),
191
+ strAttr(VX_ATTR.workspaceName, run.workspaceName),
192
+ strAttr(VX_ATTR.command, run.command),
193
+ strAttr(VX_ATTR.requestedTasks, run.requestedTasks.join(',')),
194
+ strAttr(VX_ATTR.cachePolicy, run.cachePolicy),
195
+ intAttr(VX_ATTR.concurrency, run.concurrency),
196
+ boolAttr(VX_ATTR.ci, run.ci),
197
+ strAttr(VX_ATTR.os, run.os),
198
+ strAttr(VX_ATTR.arch, run.arch),
199
+ strAttr(VX_ATTR.version, run.vxVersion),
200
+ ]
201
+ if (run.flow !== null) attrs.push(strAttr(VX_ATTR.flow, run.flow))
202
+ if (run.commitSha !== null) attrs.push(strAttr(SEMCONV.vcsHeadRevision, run.commitSha))
203
+ if (run.branch !== null) attrs.push(strAttr(SEMCONV.vcsHeadName, run.branch))
204
+ if (run.defaultBranch !== null) attrs.push(strAttr(VX_ATTR.defaultBranch, run.defaultBranch))
205
+ if (run.dirty !== null) attrs.push(boolAttr(VX_ATTR.dirty, run.dirty))
206
+ if (run.ciProvider !== null) attrs.push(strAttr(VX_ATTR.ciProvider, run.ciProvider))
207
+ if (run.host !== null) attrs.push(strAttr(VX_ATTR.host, run.host))
208
+ for (const [k, v] of Object.entries(run.tags)) attrs.push(strAttr(`${VX_ATTR.tagPrefix}${k}`, v))
209
+ if (summary !== undefined) {
210
+ attrs.push(
211
+ intAttr(VX_ATTR.runStartedAt, summary.startedAt),
212
+ intAttr(VX_ATTR.runEndedAt, summary.endedAt),
213
+ intAttr(VX_ATTR.runDurationMs, summary.totalDurationMs),
214
+ intAttr(VX_ATTR.runTaskCount, summary.taskCount),
215
+ intAttr(VX_ATTR.runFailedCount, summary.failedCount),
216
+ intAttr(VX_ATTR.runAbortedCount, summary.abortedCount),
217
+ intAttr(VX_ATTR.runHitCount, summary.hitCount),
218
+ intAttr(VX_ATTR.runHitLocalCount, summary.hitLocalCount),
219
+ intAttr(VX_ATTR.runHitRemoteCount, summary.hitRemoteCount),
220
+ boolAttr(VX_ATTR.runExitOk, summary.exitOk),
221
+ strAttr(SEMCONV.pipelineResult, pipelineResult(summary)),
222
+ )
223
+ }
224
+ return attrs
225
+ }
226
+
227
+ /**
228
+ * `cicd.pipeline.result`, the task enum's run-level twin. A red run with
229
+ * nothing failed and something aborted is a stopped one.
230
+ */
231
+ function pipelineResult(s: RunSummaryRecord): string {
232
+ if (s.exitOk) return 'success'
233
+ return s.failedCount === 0 && s.abortedCount > 0 ? 'cancellation' : 'failure'
234
+ }
235
+
236
+ /** The root span is ERROR when the run is red: UNSET read as a clean run. */
237
+ export function runStatusCode(summary?: RunSummaryRecord): number {
238
+ return summary?.exitOk === false ? STATUS_ERROR : STATUS_UNSET
239
+ }
240
+
241
+ /** What a task span needs to identify its run without its root span. */
242
+ interface TaskSpanRunContext {
243
+ runId: string
244
+ workspaceId: string
245
+ /** The run's canonical start (epoch ms) — the storage key's base. */
246
+ startedAt: number
247
+ }
248
+
249
+ /**
250
+ * `cicd.pipeline.task.run.result` is an enum (success, failure, error,
251
+ * timeout, cancellation, skip). vx's own status went out verbatim, so
252
+ * `failed`, `skipped`, `aborted` and the two hit statuses matched none of it
253
+ * and a conventions-aware backend counted no task as failed (F-12). The
254
+ * exact status still rides `vx.task.status`.
255
+ */
256
+ function runResult(t: TaskTelemetry): string {
257
+ switch (t.status) {
258
+ case 'failed':
259
+ return t.timedOut === true ? 'timeout' : 'failure'
260
+ case 'skipped':
261
+ return 'skip'
262
+ case 'aborted':
263
+ return 'cancellation'
264
+ default:
265
+ return 'success'
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Attributes for a child `vx.task` span — every `TaskTelemetry` field.
271
+ *
272
+ * The wallclock offsets ride as int64 STRINGS (OTLP's own int64 encoding), not
273
+ * numbers: they are nanoseconds, and a receiver reconstructing a task's
274
+ * `started_at` from them is computing a dedup key that must match the value
275
+ * the native ingest path derives, to the millisecond.
276
+ *
277
+ * Nothing here is unbounded: every value is an id, an enum or a number, so a
278
+ * collector's attribute-value limit cannot cut one. The largest thing this
279
+ * exporter ships is a task's captured tail, which travels as a LOG record
280
+ * (`vx.log.*`) carrying its own full length, so a cut there is visible.
281
+ */
282
+ export function taskSpanAttributes(t: TaskTelemetry, run: TaskSpanRunContext): KeyValue[] {
283
+ const attrs: KeyValue[] = [
284
+ strAttr(SEMCONV.taskName, t.taskId),
285
+ strAttr(SEMCONV.taskRunResult, runResult(t)),
286
+ strAttr(VX_ATTR.taskStatus, t.status),
287
+ // Which run, which workspace, and when that run began. Required, not
288
+ // optional: OTLP is re-batched in transit, so a task span can arrive in a
289
+ // payload its root span is not in, and a span that can only be read
290
+ // alongside its parent is a span a collector can silently strand. These
291
+ // three make it attributable on its own — and `run_started_at` is what
292
+ // lets a receiver derive the SAME storage key it would have derived from
293
+ // the complete trace, so the two arrival orders converge instead of
294
+ // duplicating.
295
+ strAttr(SEMCONV.pipelineRunId, run.runId),
296
+ strAttr(VX_ATTR.workspaceId, run.workspaceId),
297
+ intAttr(VX_ATTR.taskRunStartedAt, run.startedAt),
298
+ strAttr(VX_ATTR.taskProject, t.project),
299
+ strAttr(VX_ATTR.taskTask, t.task),
300
+ strAttr(VX_ATTR.cacheSource, t.cacheSource),
301
+ intAttr(VX_ATTR.taskExitCode, t.exitCode),
302
+ intAttr(VX_ATTR.taskDurationMs, t.durationMs),
303
+ ]
304
+ if (t.hash !== undefined) attrs.push(strAttr(VX_ATTR.taskHash, t.hash))
305
+ if (t.cpuMs !== undefined) attrs.push(intAttr(VX_ATTR.cpuMs, t.cpuMs))
306
+ if (t.where !== undefined) attrs.push(strAttr(VX_ATTR.taskWhere, t.where))
307
+ // A skipped task's root blocker (a task id, as the record carries it).
308
+ if (t.blockedBy !== undefined) attrs.push(strAttr(VX_ATTR.taskBlockedBy, t.blockedBy))
309
+ // vx's own deadline killed the task: the 143 is a timeout, not a signal.
310
+ if (t.timedOut === true) attrs.push(boolAttr(VX_ATTR.taskTimedOut, true))
311
+ if (t.sandboxViolations !== undefined)
312
+ attrs.push(intAttr(VX_ATTR.taskSandboxViolations, t.sandboxViolations))
313
+ // Why a persistent task never became ready ('timeout' | 'exited' | 'spawn').
314
+ if (t.notReady !== undefined) attrs.push(strAttr(VX_ATTR.taskNotReady, t.notReady))
315
+ if (t.outputs !== undefined) attrs.push(strAttr(VX_ATTR.taskOutputs, t.outputs))
316
+ if (t.peakRssBytes !== undefined) attrs.push(intAttr(VX_ATTR.peakRssBytes, t.peakRssBytes))
317
+ if (t.attempts !== undefined) attrs.push(intAttr(VX_ATTR.taskAttempts, t.attempts))
318
+ if (t.wallclockStartNs !== undefined)
319
+ attrs.push(int64Attr(VX_ATTR.wallclockStartNs, t.wallclockStartNs))
320
+ if (t.wallclockEndNs !== undefined)
321
+ attrs.push(int64Attr(VX_ATTR.wallclockEndNs, t.wallclockEndNs))
322
+ return attrs
323
+ }
324
+
325
+ /** A failed task maps to span status ERROR. Everything else stays UNSET. */
326
+ export function taskStatusCode(t: TaskTelemetry): number {
327
+ if (t.status === 'failed') return STATUS_ERROR
328
+ return STATUS_UNSET
329
+ }
330
+
331
+ // --- envelope builders -------------------------------------------------
332
+
333
+ /** Wrap spans in an ExportTraceServiceRequest. */
334
+ export function buildTraceRequest(
335
+ serviceName: string,
336
+ vxVersion: string,
337
+ spans: OtlpSpan[],
338
+ resource?: Readonly<Record<string, string>>,
339
+ ): unknown {
340
+ return {
341
+ resourceSpans: [
342
+ {
343
+ resource: { attributes: resourceAttributes(serviceName, vxVersion, resource) },
344
+ scopeSpans: [{ scope: { name: 'vx', version: vxVersion }, spans }],
345
+ },
346
+ ],
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Build an ExportMetricsServiceRequest from a run summary. Each count is the
352
+ * RUN's, so it is a DELTA over the run's own interval: sent CUMULATIVE with
353
+ * no start time, two runs of 10 tasks read as a series that never rose, and
354
+ * parallel jobs interleaved on one series (item 927).
355
+ */
356
+ export function buildMetricsRequest(
357
+ serviceName: string,
358
+ summary: RunSummaryRecord,
359
+ nowUnixNano: string,
360
+ startUnixNano: string,
361
+ resource?: Readonly<Record<string, string>>,
362
+ ): unknown {
363
+ const point = (value: number, attrs: KeyValue[] = []) => ({
364
+ asInt: String(value),
365
+ startTimeUnixNano: startUnixNano,
366
+ timeUnixNano: nowUnixNano,
367
+ attributes: attrs,
368
+ })
369
+ const sum = (name: string, dataPoints: ReturnType<typeof point>[]) => ({
370
+ name,
371
+ sum: { dataPoints, aggregationTemporality: AGG_DELTA, isMonotonic: true },
372
+ })
373
+ const gauge = (name: string, value: number) => ({
374
+ name,
375
+ gauge: {
376
+ dataPoints: [{ asDouble: value, timeUnixNano: nowUnixNano, attributes: [] }],
377
+ },
378
+ })
379
+ return {
380
+ resourceMetrics: [
381
+ {
382
+ resource: { attributes: resourceAttributes(serviceName, summary.run.vxVersion, resource) },
383
+ scopeMetrics: [
384
+ {
385
+ scope: { name: 'vx', version: summary.run.vxVersion },
386
+ metrics: [
387
+ sum('vx.tasks.total', [point(summary.taskCount)]),
388
+ sum('vx.tasks.failed', [point(summary.failedCount)]),
389
+ // One metric, a point per source: two entries of one name
390
+ // are two metrics a backend may keep only one of.
391
+ sum('vx.tasks.cache_hits', [
392
+ point(summary.hitLocalCount, [strAttr('source', 'local')]),
393
+ point(summary.hitRemoteCount, [strAttr('source', 'remote')]),
394
+ ]),
395
+ gauge('vx.run.duration_ms', summary.totalDurationMs),
396
+ ],
397
+ },
398
+ ],
399
+ },
400
+ ],
401
+ }
402
+ }
403
+
404
+ // --- logs ---------------------------------------------------------------
405
+
406
+ // OTLP severity numbers: 9 INFO, 17 ERROR.
407
+ const SEVERITY_INFO = 9
408
+ const SEVERITY_ERROR = 17
409
+
410
+ export interface OtlpLogRecord {
411
+ timeUnixNano: string
412
+ observedTimeUnixNano: string
413
+ severityNumber: number
414
+ severityText: string
415
+ body: { stringValue: string }
416
+ attributes: KeyValue[]
417
+ traceId?: string
418
+ spanId?: string
419
+ }
420
+
421
+ /**
422
+ * One log record per EXECUTED task, carrying its captured output tail.
423
+ *
424
+ * Per task, not per chunk. A chunk-level stream is the conventional shape for
425
+ * application logs, but a build task's output arrives as thousands of tiny
426
+ * writes and the thing anyone reads is the tail — so a record per chunk would
427
+ * multiply the payload by orders of magnitude to deliver the same bytes, and
428
+ * force every receiver to reassemble them in order before it could show
429
+ * anything. The capture buffer already bounds and orders the tail; this ships
430
+ * what it drained.
431
+ *
432
+ * `traceId`/`spanId` link each record to its task span, so a viewer opens the
433
+ * output from the span rather than by correlating ids by hand. The truncation
434
+ * counters ride along because a capped tail that reads as complete is worse
435
+ * than one that says what it lost.
436
+ */
437
+ export function buildLogsRequest(args: {
438
+ serviceName: string
439
+ vxVersion: string
440
+ runId: string
441
+ /** The run's workspace — a receiver has no other way to route the record. */
442
+ workspaceId: string
443
+ entries: readonly TaskLogEntry[]
444
+ timeUnixNano: string
445
+ traceId?: string
446
+ spanIdFor?: (taskId: string) => string | undefined
447
+ resource?: Readonly<Record<string, string>>
448
+ }): unknown {
449
+ const logRecords: OtlpLogRecord[] = args.entries.map((e) => {
450
+ const failed = e.status === 'failed'
451
+ const attrs: KeyValue[] = [
452
+ strAttr(SEMCONV.pipelineRunId, args.runId),
453
+ strAttr(VX_ATTR.workspaceId, args.workspaceId),
454
+ strAttr(SEMCONV.taskName, e.taskId),
455
+ strAttr(VX_ATTR.logStatus, e.status),
456
+ intAttr(VX_ATTR.logCharsFull, e.charsFull),
457
+ intAttr(VX_ATTR.logTruncatedHead, e.truncatedHeadChars),
458
+ ]
459
+ if (e.hash !== undefined) attrs.push(strAttr(VX_ATTR.taskHash, e.hash))
460
+ const spanId = args.spanIdFor?.(e.taskId)
461
+ return {
462
+ timeUnixNano: args.timeUnixNano,
463
+ observedTimeUnixNano: args.timeUnixNano,
464
+ severityNumber: failed ? SEVERITY_ERROR : SEVERITY_INFO,
465
+ severityText: failed ? 'ERROR' : 'INFO',
466
+ body: { stringValue: e.content },
467
+ attributes: attrs,
468
+ ...(args.traceId ? { traceId: args.traceId } : {}),
469
+ ...(spanId ? { spanId } : {}),
470
+ }
471
+ })
472
+ return {
473
+ resourceLogs: [
474
+ {
475
+ resource: {
476
+ attributes: resourceAttributes(args.serviceName, args.vxVersion, args.resource),
477
+ },
478
+ scopeLogs: [{ scope: { name: 'vx', version: args.vxVersion }, logRecords }],
479
+ },
480
+ ],
481
+ }
482
+ }