Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@modelcontextprotocol/sdk": "^1.26.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/api-logs": "^0.205.0",
"@opentelemetry/core": "^2.8.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.205.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.205.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.205.0",
Expand Down
4 changes: 4 additions & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export {
loadSkillFromDir,
validateSkillDir,
} from './skills/loader.js';
export {
SqliteSpanExporter,
type SqliteSpanExporterOptions,
} from './telemetry/sqlite_span_exporter.js';
export {
RunSkillInlineScriptErrorCode,
RunSkillInlineScriptTool,
Expand Down
78 changes: 78 additions & 0 deletions core/src/telemetry/db/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {BigIntType, Entity, Index, PrimaryKey, Property} from '@mikro-orm/core';

/**
* An epoch-nanosecond column, stored as a SQL `bigint` and read as a string.
*
* The `bigint` column keeps the on-disk value identical to the adk-python
* exporter. The read is cast to text because the `sqlite3` driver hands
* JavaScript a double for integer columns, which rounds epoch nanoseconds
* (~1.7e18, far above `Number.MAX_SAFE_INTEGER`).
*
* The cast also applies to `ORDER BY`, which would then sort lexicographically,
* so callers must order these values themselves.
*/
class UnixNanoType extends BigIntType<'string'> {
constructor() {
super('string');
}

override convertToJSValueSQL(key: string): string {
return `cast(${key} as text)`;
}
}

/**
* A single exported OpenTelemetry span, persisted by `SqliteSpanExporter`.
*
* The table and column names mirror the adk-python exporter so that a database
* file written by either implementation is readable by the other.
*
* Timestamps are stored as `bigint` columns and surfaced as strings: epoch
* nanoseconds exceed `Number.MAX_SAFE_INTEGER`, so a `number` mapping would
* silently round them. See {@link UnixNanoType}.
*/
@Index({name: 'spans_session_id_idx', properties: ['sessionId']})
@Index({name: 'spans_trace_id_idx', properties: ['traceId']})
@Entity({tableName: 'spans'})
export class StorageSpan {
@PrimaryKey({type: 'string', fieldName: 'span_id'})
spanId!: string;

@Property({type: 'string', fieldName: 'trace_id'})
traceId!: string;

@Property({type: 'string', fieldName: 'parent_span_id', nullable: true})
parentSpanId?: string;

@Property({type: 'string'})
name!: string;

@Property({
type: new UnixNanoType(),
fieldName: 'start_time_unix_nano',
nullable: true,
})
startTimeUnixNano?: string;

@Property({
type: new UnixNanoType(),
fieldName: 'end_time_unix_nano',
nullable: true,
})
endTimeUnixNano?: string;

@Property({type: 'string', fieldName: 'session_id', nullable: true})
sessionId?: string;

@Property({type: 'string', fieldName: 'invocation_id', nullable: true})
invocationId?: string;

@Property({type: 'text', fieldName: 'attributes_json', nullable: true})
attributesJson?: string;
}
190 changes: 190 additions & 0 deletions core/src/telemetry/db/span_mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {EntityData} from '@mikro-orm/core';
import {
Attributes,
AttributeValue,
HrTime,
SpanContext,
SpanKind,
SpanStatusCode,
TraceFlags,
} from '@opentelemetry/api';
import {emptyResource} from '@opentelemetry/resources';
import {ReadableSpan} from '@opentelemetry/sdk-trace-base';

import {logger} from '../../utils/logger.js';
import {version} from '../../version.js';
import {StorageSpan} from './schema.js';

const SESSION_ID_ATTRIBUTE = 'gcp.vertex.agent.session_id';
const INVOCATION_ID_ATTRIBUTE = 'gcp.vertex.agent.invocation_id';
const CONVERSATION_ID_ATTRIBUTE = 'gen_ai.conversation.id';

const INSTRUMENTATION_SCOPE_NAME = 'gcp.vertex.agent';
const NANOS_PER_SECOND = 1_000_000_000n;

/** Converts an OpenTelemetry `HrTime` to epoch nanoseconds. */
export function hrTimeToUnixNanos(hrTime: HrTime): string {
return (BigInt(hrTime[0]) * NANOS_PER_SECOND + BigInt(hrTime[1])).toString();
}

/**
* Serializes span attributes to JSON, falling back to an empty object.
*
* A payload that was already unserializable upstream arrives as the
* `'<not serializable>'` string that `tracing.ts` substitutes for it, so the
* fallback here only guards a value that is not a real `AttributeValue`.
*/
export function serializeAttributes(
attributes: Record<string, unknown>,
): string {
try {
return JSON.stringify(attributes);
} catch (e: unknown) {
logger.debug('Failed to serialize span attributes:', e);
return '{}';
}
}

/**
* Parses a stored attributes blob back into OpenTelemetry attributes.
*
* Never throws: malformed JSON and non-object payloads yield `{}`, and entries
* whose value is not a legal `AttributeValue` are dropped.
*/
export function deserializeAttributes(
attributesJson: string | undefined,
): Attributes {
if (!attributesJson) {
return {};
}

let parsed: unknown;
try {
parsed = JSON.parse(attributesJson);
} catch (e: unknown) {
logger.debug('Failed to deserialize span attributes:', e);
return {};
}

if (!isRecord(parsed)) {
return {};
}

const attributes: Attributes = {};
for (const [key, value] of Object.entries(parsed)) {
if (isAttributeValue(value)) {
attributes[key] = value;
}
}
return attributes;
}

/** Orders stored spans oldest first. */
export function compareByStartTime(a: StorageSpan, b: StorageSpan): number {
const delta = toNanos(a.startTimeUnixNano) - toNanos(b.startTimeUnixNano);
if (delta === 0n) {
return 0;
}
return delta < 0n ? -1 : 1;
}

/** Maps a finished span to the row that persists it. */
export function toStorageSpanData(span: ReadableSpan): EntityData<StorageSpan> {
const spanContext = span.spanContext();
return {
spanId: spanContext.spanId,
traceId: spanContext.traceId,
parentSpanId: span.parentSpanContext?.spanId,
name: span.name,
startTimeUnixNano: hrTimeToUnixNanos(span.startTime),
endTimeUnixNano: hrTimeToUnixNanos(span.endTime),
// `||`, not `??`: the reference falls through on an empty session id too.
sessionId:
stringAttribute(span.attributes, SESSION_ID_ATTRIBUTE) ||
stringAttribute(span.attributes, CONVERSATION_ID_ATTRIBUTE),
invocationId: stringAttribute(span.attributes, INVOCATION_ID_ATTRIBUTE),
attributesJson: serializeAttributes(span.attributes),
};
}

/** Reconstructs a readable span from the row that persisted it. */
export function toReadableSpan(row: StorageSpan): ReadableSpan {
const spanContext: SpanContext = {
traceId: row.traceId,
spanId: row.spanId,
traceFlags: TraceFlags.SAMPLED,
isRemote: false,
};
const startNanos = toNanos(row.startTimeUnixNano);
const endNanos = toNanos(row.endTimeUnixNano);

return {
name: row.name,
kind: SpanKind.INTERNAL,
spanContext: () => spanContext,
parentSpanContext: row.parentSpanId
? {...spanContext, spanId: row.parentSpanId}
: undefined,
startTime: nanosToHrTime(startNanos),
endTime: nanosToHrTime(endNanos),
duration: nanosToHrTime(endNanos - startNanos),
status: {code: SpanStatusCode.UNSET},
attributes: deserializeAttributes(row.attributesJson),
links: [],
events: [],
ended: true,
resource: emptyResource(),
instrumentationScope: {name: INSTRUMENTATION_SCOPE_NAME, version},
droppedAttributesCount: 0,
droppedEventsCount: 0,
droppedLinksCount: 0,
};
}

function nanosToHrTime(total: bigint): HrTime {
return [Number(total / NANOS_PER_SECOND), Number(total % NANOS_PER_SECOND)];
}

/** Reads a stored timestamp, treating a missing one as the epoch. */
function toNanos(stored: string | undefined): bigint {
return BigInt(stored ?? '0');
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isAttributePrimitive(
value: unknown,
): value is string | number | boolean {
const type = typeof value;
return type === 'string' || type === 'number' || type === 'boolean';
}

function isAttributeValue(value: unknown): value is AttributeValue {
if (isAttributePrimitive(value)) {
return true;
}
if (!Array.isArray(value)) {
return false;
}
const present = value.filter((entry) => entry != null);
const [first] = present;
return present.every(
(entry) => typeof entry === typeof first && isAttributePrimitive(entry),
);
}

function stringAttribute(
attributes: Attributes,
key: string,
): string | undefined {
const value = attributes[key];
return typeof value === 'string' ? value : undefined;
}
Loading
Loading