@agentscope/studio
Version:
AgentScope Studio is a powerful local monitoring and visualization tool designed to provide real-time insights into your system's performance and behavior.
639 lines (638 loc) • 31.8 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SpanDao = void 0;
const objectUtils_1 = require("../../../shared/src/utils/objectUtils");
const ModelInvocationView_1 = require("../models/ModelInvocationView");
const Trace_1 = require("../models/Trace");
class SpanDao {
static saveSpans(dataArray) {
return __awaiter(this, void 0, void 0, function* () {
try {
// Create SpanTable instances with embedded resource and scope data
const spans = dataArray.map((data) => {
// Extract key fields for indexing
const serviceName = this.extractServiceName(data.resource);
const operationName = this.extractOperationName(data.attributes);
const instrumentationName = this.extractInstrumentationName(data.scope);
const instrumentationVersion = this.extractInstrumentationVersion(data.scope);
const model = this.extractModel(data.attributes);
const inputTokens = this.extractInputTokens(data.attributes);
const outputTokens = this.extractOutputTokens(data.attributes);
const totalTokens = this.calculateTotalTokens(inputTokens, outputTokens);
const statusCode = data.status.code || 0;
const span = new Trace_1.SpanTable();
Object.assign(span, {
id: data.spanId, // Use spanId as the primary key
traceId: data.traceId,
spanId: data.spanId,
traceState: data.traceState,
parentSpanId: data.parentSpanId,
flags: data.flags,
name: data.name,
kind: data.kind, // Now it's a number (OpenTelemetry API enum)
startTimeUnixNano: data.startTimeUnixNano,
endTimeUnixNano: data.endTimeUnixNano,
attributes: data.attributes,
droppedAttributesCount: data.droppedAttributesCount,
events: data.events,
droppedEventsCount: data.droppedEventsCount,
links: data.links,
droppedLinksCount: data.droppedLinksCount,
status: data.status,
resource: data.resource,
scope: data.scope,
// Additional fields for our application
statusCode: statusCode,
serviceName: serviceName,
operationName: operationName,
instrumentationName: instrumentationName,
instrumentationVersion: instrumentationVersion,
model: model,
inputTokens: inputTokens,
outputTokens: outputTokens,
totalTokens: totalTokens,
conversationId: data.conversationId,
latencyNs: data.latencyNs,
});
return span;
});
// Save all spans in a single transaction
return yield Trace_1.SpanTable.save(spans);
}
catch (error) {
console.error('Error saving spans:', error);
throw error;
}
});
}
static getSpansByConversationId(conversationId) {
return __awaiter(this, void 0, void 0, function* () {
try {
const spans = yield Trace_1.SpanTable.find({
where: { conversationId },
order: { startTimeUnixNano: 'ASC' },
});
return spans.map((span) => ({
traceId: span.traceId,
spanId: span.spanId,
traceState: span.traceState,
parentSpanId: span.parentSpanId,
flags: span.flags,
name: span.name,
kind: span.kind,
startTimeUnixNano: span.startTimeUnixNano,
endTimeUnixNano: span.endTimeUnixNano,
attributes: span.attributes,
droppedAttributesCount: span.droppedAttributesCount || 0,
events: (span.events || []),
droppedEventsCount: span.droppedEventsCount || 0,
links: (span.links || []),
droppedLinksCount: span.droppedLinksCount || 0,
status: span.status,
resource: span.resource,
scope: span.scope,
conversationId: span.conversationId,
latencyNs: span.latencyNs,
}));
}
catch (error) {
console.error(`Error fetching spans for conversationId ${conversationId}:`, error);
throw error;
}
});
}
// Helper methods to extract key fields from nested data
static calculateTotalTokens(inputTokens, outputTokens) {
if (typeof inputTokens === 'number' &&
typeof outputTokens === 'number') {
return inputTokens + outputTokens;
}
if (typeof inputTokens === 'number') {
return inputTokens;
}
if (typeof outputTokens === 'number') {
return outputTokens;
}
return undefined;
}
static extractServiceName(resource) {
const value = (0, objectUtils_1.getNestedValue)(resource.attributes, 'service.name');
return typeof value === 'string' ? value : undefined;
}
static extractOperationName(attributes) {
const value = (0, objectUtils_1.getNestedValue)(attributes, 'gen_ai.operation.name');
return typeof value === 'string' ? value : undefined;
}
static extractInstrumentationName(scope) {
const value = (0, objectUtils_1.getNestedValue)(scope.attributes, 'server.name');
return typeof value === 'string' ? value : undefined;
}
static extractInstrumentationVersion(scope) {
const value = (0, objectUtils_1.getNestedValue)(scope.attributes, 'server.version');
return typeof value === 'string' ? value : undefined;
}
static extractModel(attributes) {
const value = (0, objectUtils_1.getNestedValue)(attributes, 'gen_ai.request.model');
return typeof value === 'string' ? value : undefined;
}
static extractInputTokens(attributes) {
const value = (0, objectUtils_1.getNestedValue)(attributes, 'gen_ai.usage.input_tokens');
return typeof value === 'number' ? value : undefined;
}
static extractOutputTokens(attributes) {
const value = (0, objectUtils_1.getNestedValue)(attributes, 'gen_ai.usage.output_tokens');
return typeof value === 'number' ? value : undefined;
}
// Trace listing and filtering methods
static getLatestTraces() {
return __awaiter(this, arguments, void 0, function* (limit = 10) {
return yield Trace_1.SpanTable.find({
order: { startTimeUnixNano: 'DESC' },
take: limit,
});
});
}
static getTracesByTraceId(traceId) {
return __awaiter(this, void 0, void 0, function* () {
return yield Trace_1.SpanTable.find({
where: { traceId },
order: { startTimeUnixNano: 'ASC' },
});
});
}
static getSpanById(spanId) {
return __awaiter(this, void 0, void 0, function* () {
return yield Trace_1.SpanTable.findOne({
where: { spanId },
});
});
}
static getModelInvocationViewData() {
return __awaiter(this, void 0, void 0, function* () {
const res = yield ModelInvocationView_1.ModelInvocationView.find();
if (res.length > 0) {
return res[0];
}
else {
throw new Error('ModelInvocationView data not found');
}
});
}
static getModelInvocationData(conversationId) {
return __awaiter(this, void 0, void 0, function* () {
// 1. Basic statistics
const basicStats = yield Trace_1.SpanTable.createQueryBuilder('span')
.select(`COUNT(CASE
WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
THEN 1
END)`, 'totalInvocations')
.addSelect(`COUNT(CASE
WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
AND span.totalTokens IS NOT NULL
THEN 1
END)`, 'chatInvocations')
.where('span.conversationId = :conversationId', { conversationId })
.getRawOne();
// 2. Chat token statistics (total and average)
const chatTokenStats = yield Trace_1.SpanTable.createQueryBuilder('span')
.select([
// Total - input tokens
`COALESCE(SUM(
CASE WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
AND span.totalTokens IS NOT NULL
THEN CAST(COALESCE(span.inputTokens, 0) AS INTEGER)
ELSE 0 END
), 0) as totalPromptTokens`,
// Total - output tokens
`COALESCE(SUM(
CASE WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
AND span.totalTokens IS NOT NULL
THEN CAST(COALESCE(span.outputTokens, 0) AS INTEGER)
ELSE 0 END
), 0) as totalCompletionTokens`,
// Total - total tokens
`COALESCE(SUM(
CASE WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
AND span.totalTokens IS NOT NULL
THEN CAST(COALESCE(span.totalTokens, 0) AS INTEGER)
ELSE 0 END
), 0) as totalTokens`,
// Average - input tokens
`COALESCE(
CAST(SUM(
CASE WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
AND span.totalTokens IS NOT NULL
THEN CAST(COALESCE(span.inputTokens, 0) AS INTEGER)
ELSE 0 END
) AS FLOAT) /
NULLIF(COUNT(CASE WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
AND span.totalTokens IS NOT NULL THEN 1 END), 0)
, 0) as avgPromptTokens`,
// Average - output tokens
`COALESCE(
CAST(SUM(
CASE WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
AND span.totalTokens IS NOT NULL
THEN CAST(COALESCE(span.outputTokens, 0) AS INTEGER)
ELSE 0 END
) AS FLOAT) /
NULLIF(COUNT(CASE WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
AND span.totalTokens IS NOT NULL THEN 1 END), 0)
, 0) as avgCompletionTokens`,
// Average - total tokens
`COALESCE(
CAST(SUM(
CASE WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
AND span.totalTokens IS NOT NULL
THEN CAST(COALESCE(span.totalTokens, 0) AS INTEGER)
ELSE 0 END
) AS FLOAT) /
NULLIF(COUNT(CASE WHEN (span.operationName = 'chat'
OR span.operationName = 'chat_model')
AND span.totalTokens IS NOT NULL THEN 1 END), 0)
, 0) as avgTotalTokens`,
])
.where('span.conversationId = :conversationId', { conversationId })
.getRawOne();
// 3. Model invocation statistics (grouped by model)
const modelInvocations = yield Trace_1.SpanTable.createQueryBuilder('span')
.select(['span.model as modelName', 'COUNT(*) as invocations'])
.where('span.conversationId = :conversationId', { conversationId })
.andWhere("(span.operationName = 'chat' OR span.operationName = 'chat_model')")
.andWhere('span.totalTokens IS NOT NULL')
.groupBy('modelName')
.getRawMany();
// 4. Model token statistics (grouped by model)
const modelTokenStats = yield Trace_1.SpanTable.createQueryBuilder('span')
.select([
'span.model as modelName',
// Total
`SUM(CAST(COALESCE(span.inputTokens, 0) AS INTEGER)) as totalPromptTokens`,
`SUM(CAST(COALESCE(span.outputTokens, 0) AS INTEGER)) as totalCompletionTokens`,
`SUM(CAST(COALESCE(span.totalTokens, 0) AS INTEGER)) as totalTokens`,
// Average
`CAST(SUM(CAST(COALESCE(span.inputTokens, 0) AS INTEGER)) AS FLOAT) / COUNT(*) as avgPromptTokens`,
`CAST(SUM(CAST(COALESCE(span.outputTokens, 0) AS INTEGER)) AS FLOAT) / COUNT(*) as avgCompletionTokens`,
`CAST(SUM(CAST(COALESCE(span.totalTokens, 0) AS INTEGER)) AS FLOAT) / COUNT(*) as avgTotalTokens`,
])
.where('span.conversationId = :conversationId', { conversationId })
.andWhere("(span.operationName = 'chat' OR span.operationName = 'chat_model')")
.andWhere('span.totalTokens IS NOT NULL')
.groupBy('modelName')
.getRawMany();
// 5. Build return structure
return {
modelInvocations: Number(basicStats.totalInvocations),
chat: {
modelInvocations: Number(basicStats.chatInvocations),
totalTokens: {
promptTokens: Number(chatTokenStats.totalPromptTokens),
completionTokens: Number(chatTokenStats.totalCompletionTokens),
totalTokens: Number(chatTokenStats.totalTokens),
},
avgTokens: {
promptTokens: Number(chatTokenStats.avgPromptTokens),
completionTokens: Number(chatTokenStats.avgCompletionTokens),
totalTokens: Number(chatTokenStats.avgTotalTokens),
},
modelInvocationsByModel: modelInvocations.map((stat) => ({
modelName: stat.modelName || 'unknown',
invocations: Number(stat.invocations),
})),
totalTokensByModel: modelTokenStats.map((stat) => ({
modelName: stat.modelName || 'unknown',
promptTokens: Number(stat.totalPromptTokens),
completionTokens: Number(stat.totalCompletionTokens),
totalTokens: Number(stat.totalTokens),
})),
avgTokensByModel: modelTokenStats.map((stat) => ({
modelName: stat.modelName || 'unknown',
promptTokens: Number(stat.avgPromptTokens),
completionTokens: Number(stat.avgCompletionTokens),
totalTokens: Number(stat.avgTotalTokens),
})),
},
};
});
}
static deleteSpansByConversationIds(conversationIds) {
return __awaiter(this, void 0, void 0, function* () {
try {
if (conversationIds.length === 0) {
return 0;
}
const result = yield Trace_1.SpanTable.createQueryBuilder()
.delete()
.where('conversationId IN (:...conversationIds)', {
conversationIds,
})
.execute();
return result.affected || 0;
}
catch (error) {
console.error('Error deleting spans by conversationIds:', error);
throw error;
}
});
}
/**
* Get unique trace IDs with aggregated information
* Uses the same parameter pattern as RunDao.getProjects (TableRequestParams)
*
* @param params - TableRequestParams containing pagination, sort, and filters
* @returns TableData<Trace> with list, total, page, pageSize
*/
static getTraces(params) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
try {
const { pagination, sort, filters } = params;
// Build base query - group by traceId and aggregate information
const spanCountSubquery = `(
SELECT COUNT(*) FROM span_table s WHERE s.traceId = span.traceId
)`;
const totalTokensSubquery = `(
SELECT SUM(COALESCE(s.totalTokens, 0)) FROM span_table s WHERE s.traceId = span.traceId
)`;
// Subquery to get the name of the span with the earliest startTimeUnixNano
const nameSubquery = `(
SELECT s.name FROM span_table s
WHERE s.traceId = span.traceId
AND s.startTimeUnixNano = (
SELECT MIN(s2.startTimeUnixNano)
FROM span_table s2
WHERE s2.traceId = span.traceId
)
LIMIT 1
)`;
const queryBuilder = Trace_1.SpanTable.createQueryBuilder('span')
.select('span.traceId', 'traceId')
.addSelect('MIN(span.startTimeUnixNano)', 'startTime')
.addSelect('MAX(span.endTimeUnixNano)', 'endTime')
.addSelect('MAX(span.statusCode)', 'status')
.addSelect(nameSubquery, 'name')
.addSelect(spanCountSubquery, 'spanCount')
.addSelect(totalTokensSubquery, 'totalTokens')
.groupBy('span.traceId');
// Apply name filter - use the subquery directly in HAVING
if (filters === null || filters === void 0 ? void 0 : filters.traceName) {
const filterValue = typeof filters.traceName === 'object' &&
filters.traceName !== null &&
'value' in filters.traceName
? filters.traceName.value
: String(filters.traceName);
if (filterValue) {
// Use the same subquery in HAVING clause to filter by name
queryBuilder.having(`${nameSubquery} LIKE :traceNameFilter`, {
traceNameFilter: `%${filterValue}%`,
});
}
}
// Apply traceId filter
if (filters === null || filters === void 0 ? void 0 : filters.traceId) {
const filterValue = typeof filters.traceId === 'object' &&
filters.traceId !== null &&
'value' in filters.traceId
? filters.traceId.value
: String(filters.traceId);
if (filterValue) {
queryBuilder.having('span.traceId LIKE :traceIdFilter', {
traceIdFilter: `%${filterValue}%`,
});
}
}
// Apply time range filter
if (filters === null || filters === void 0 ? void 0 : filters.timeRange) {
const timeRangeFilter = filters.timeRange;
if (timeRangeFilter.operator === 'between' &&
Array.isArray(timeRangeFilter.value) &&
timeRangeFilter.value.length === 2) {
const [rangeStart, rangeEnd] = timeRangeFilter.value;
if (rangeStart && rangeEnd) {
queryBuilder.andWhere('span.startTimeUnixNano >= :rangeStart AND span.startTimeUnixNano <= :rangeEnd', { rangeStart, rangeEnd });
}
else if (rangeStart) {
queryBuilder.andWhere('span.startTimeUnixNano >= :rangeStart', { rangeStart });
}
else if (rangeEnd) {
queryBuilder.andWhere('span.startTimeUnixNano <= :rangeEnd', { rangeEnd });
}
}
}
// Apply sorting
const sortField = (sort === null || sort === void 0 ? void 0 : sort.field) || 'startTime';
const sortOrder = ((_a = sort === null || sort === void 0 ? void 0 : sort.order) === null || _a === void 0 ? void 0 : _a.toUpperCase()) === 'ASC' ? 'ASC' : 'DESC';
switch (sortField) {
case 'startTime':
queryBuilder.orderBy('MIN(span.startTimeUnixNano)', sortOrder);
break;
case 'duration':
queryBuilder.orderBy('(MAX(span.endTimeUnixNano) - MIN(span.startTimeUnixNano))', sortOrder);
break;
case 'status':
queryBuilder.orderBy('MAX(span.statusCode)', sortOrder);
break;
case 'totalTokens':
queryBuilder.orderBy(`(SELECT SUM(COALESCE(s.totalTokens, 0)) FROM span_table s WHERE s.traceId = span.traceId)`, sortOrder);
break;
default:
queryBuilder.orderBy('MIN(span.startTimeUnixNano)', 'DESC');
}
// Get total count (before pagination)
const countQuery = queryBuilder.clone();
const totalResult = yield countQuery.getRawMany();
const total = totalResult.length;
// Apply pagination
const page = pagination.page || 1;
const pageSize = pagination.pageSize || 10;
const skip = (page - 1) * pageSize;
queryBuilder.skip(skip).take(pageSize);
// Query paginated traces
const results = yield queryBuilder.getRawMany();
// Simple mapping - return raw data
const traces = results.map((row) => ({
traceId: row.traceId || '',
traceName: row.name || 'Unknown',
startTime: row.startTime || '0',
endTime: row.endTime || '0',
status: Number(row.status) || 0,
spanCount: Number(row.spanCount) || 0,
totalTokens: row.totalTokens !== null && row.totalTokens !== undefined
? Number(row.totalTokens)
: undefined,
}));
// Return paginated data
return {
list: traces,
total,
page,
pageSize,
};
}
catch (error) {
console.error('Error in getTraces:', error);
throw error;
}
});
}
// Get a single trace with all spans
static getTrace(traceId) {
return __awaiter(this, void 0, void 0, function* () {
try {
const spans = yield this.getTracesByTraceId(traceId);
if (spans.length === 0) {
throw new Error(`Trace with id ${traceId} not found`);
}
// Calculate trace-level statistics
const startTimes = spans.map((s) => BigInt(s.startTimeUnixNano));
const endTimes = spans.map((s) => BigInt(s.endTimeUnixNano));
const minStartTime = startTimes.reduce((a, b) => (a < b ? a : b));
const maxEndTime = endTimes.reduce((a, b) => (a > b ? a : b));
const duration = Number(maxEndTime - minStartTime) / 1e9;
// Get status (ERROR if any span has error status)
const status = spans.some((s) => s.statusCode === 2) ? 2 : 1;
// Calculate total tokens
const totalTokens = spans.reduce((sum, s) => sum + (s.totalTokens || 0), 0);
const spanDataArray = spans.map((span) => ({
traceId: span.traceId,
spanId: span.spanId,
traceState: span.traceState,
parentSpanId: span.parentSpanId,
flags: span.flags,
name: span.name,
kind: span.kind,
startTimeUnixNano: span.startTimeUnixNano,
endTimeUnixNano: span.endTimeUnixNano,
attributes: span.attributes,
droppedAttributesCount: span.droppedAttributesCount || 0,
events: (span.events || []),
droppedEventsCount: span.droppedEventsCount || 0,
links: (span.links || []),
droppedLinksCount: span.droppedLinksCount || 0,
status: span.status,
resource: span.resource,
scope: span.scope,
conversationId: span.conversationId,
latencyNs: span.latencyNs,
}));
return {
traceId,
spans: spanDataArray,
startTime: minStartTime.toString(),
endTime: maxEndTime.toString(),
duration,
status,
totalTokens: totalTokens > 0 ? totalTokens : undefined,
};
}
catch (error) {
console.error(`Error getting trace ${traceId}:`, error);
throw error;
}
});
}
// Get trace statistics
static getTraceStatistic(filters) {
return __awaiter(this, void 0, void 0, function* () {
try {
const queryBuilder = Trace_1.SpanTable.createQueryBuilder('span');
if (filters === null || filters === void 0 ? void 0 : filters.startTime) {
queryBuilder.andWhere('span.startTimeUnixNano >= :startTime', {
startTime: filters.startTime,
});
}
if (filters === null || filters === void 0 ? void 0 : filters.endTime) {
queryBuilder.andWhere('span.startTimeUnixNano <= :endTime', {
endTime: filters.endTime,
});
}
if (filters === null || filters === void 0 ? void 0 : filters.traceId) {
queryBuilder.andWhere('span.traceId LIKE :traceId', {
traceId: `%${filters.traceId}%`,
});
}
// Get total spans
const totalSpans = yield queryBuilder.getCount();
// Get unique trace count
const uniqueTracesQuery = queryBuilder
.clone()
.select('COUNT(DISTINCT span.traceId)', 'count')
.getRawOne();
const totalTraces = Number((yield uniqueTracesQuery).count || 0);
// Get error traces count
const errorTracesQuery = queryBuilder
.clone()
.select('COUNT(DISTINCT span.traceId)', 'count')
.andWhere('span.statusCode = :statusCode', { statusCode: 2 })
.getRawOne();
const errorTraces = Number((yield errorTracesQuery).count || 0);
// Get average duration
const durationQuery = yield queryBuilder
.clone()
.select('span.traceId', 'traceId')
.addSelect('MIN(span.startTimeUnixNano)', 'startTime')
.addSelect('MAX(span.endTimeUnixNano)', 'endTime')
.groupBy('span.traceId')
.getRawMany();
const durations = durationQuery.map((row) => {
const startTimeNs = BigInt(row.startTime);
const endTimeNs = BigInt(row.endTime);
return Number(endTimeNs - startTimeNs) / 1e9;
});
const avgDuration = durations.length > 0
? durations.reduce((a, b) => a + b, 0) /
durations.length
: 0;
// Get total tokens
const tokensQuery = queryBuilder
.clone()
.select('SUM(COALESCE(span.totalTokens, 0))', 'total')
.getRawOne();
const totalTokens = Number((yield tokensQuery).total || 0);
// Get traces by status
const statusQuery = yield queryBuilder
.clone()
.select('span.statusCode', 'status')
.addSelect('COUNT(DISTINCT span.traceId)', 'count')
.groupBy('span.statusCode')
.getRawMany();
const tracesByStatus = statusQuery.map((row) => ({
status: Number(row.status) || 0,
count: Number(row.count) || 0,
}));
return {
totalTraces,
totalSpans,
errorTraces,
avgDuration,
totalTokens,
tracesByStatus,
};
}
catch (error) {
console.error('Error getting trace statistics:', error);
throw error;
}
});
}
}
exports.SpanDao = SpanDao;