diff --git a/apps/workers-observability/src/tools/logs.ts b/apps/workers-observability/src/tools/logs.ts index 5e921d9f..fb7d5fbc 100644 --- a/apps/workers-observability/src/tools/logs.ts +++ b/apps/workers-observability/src/tools/logs.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -import { handleWorkerLogs, handleWorkerLogsKeys } from '@repo/mcp-common/src/api/logs' +import { handleWorkerLogs, handleWorkerLogsKeys } from '@repo/mcp-common/src/api/workers-logs' import type { MyMCP } from '../index' @@ -16,9 +16,9 @@ const limitParam = z const minutesAgoParam = z .number() .min(1) - .max(1440) + .max(10080) .default(30) - .describe('Minutes in the past to look for logs (1-1440, default 30)') + .describe('Minutes in the past to look for logs (1-10080, default 30)') const rayIdParam = z.string().optional().describe('Filter logs by specific Cloudflare Ray ID') /** @@ -53,7 +53,7 @@ export function registerLogsTools(agent: MyMCP) { } try { const { scriptName, shouldFilterErrors, limitParam, minutesAgoParam, rayId } = params - const { relevantLogs, from, to } = await handleWorkerLogs({ + const { logs, from, to } = await handleWorkerLogs({ scriptName, limit: limitParam, minutesAgo: minutesAgoParam, @@ -67,9 +67,8 @@ export function registerLogsTools(agent: MyMCP) { { type: 'text', text: JSON.stringify({ - logs: relevantLogs, + logs, stats: { - total: relevantLogs.length, timeRange: { from, to, @@ -118,7 +117,7 @@ export function registerLogsTools(agent: MyMCP) { } try { const { rayId, shouldFilterErrors, limitParam, minutesAgoParam } = params - const { relevantLogs, from, to } = await handleWorkerLogs({ + const { logs, from, to } = await handleWorkerLogs({ limit: limitParam, minutesAgo: minutesAgoParam, accountId, @@ -131,9 +130,8 @@ export function registerLogsTools(agent: MyMCP) { { type: 'text', text: JSON.stringify({ - logs: relevantLogs, + logs, stats: { - total: relevantLogs.length, timeRange: { from, to, @@ -192,7 +190,7 @@ export function registerLogsTools(agent: MyMCP) { keys: keys.map((key) => ({ key: key.key, type: key.type, - lastSeen: key.lastSeen ? new Date(key.lastSeen).toISOString() : null, + lastSeenAt: key.lastSeenAt ? new Date(key.lastSeenAt).toISOString() : null, })), stats: { total: keys.length, diff --git a/package.json b/package.json index 208e8078..5508a4c3 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "check:turbo": "run-turbo check", "test:ci": "run-vitest-ci", "test": "vitest run", - "lint": "prettier . --write", + "fix:format": "prettier . --write", "test:watch": "vitest" }, "devDependencies": { diff --git a/packages/mcp-common/src/api/logs.ts b/packages/mcp-common/src/api/logs.ts deleted file mode 100644 index 76da1e48..00000000 --- a/packages/mcp-common/src/api/logs.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { z } from 'zod' - -import { fetchCloudflareApi } from '../cloudflare-api' -import { V4Schema } from '../v4-api' - -const RelevantLogInfoSchema = z.object({ - timestamp: z.string(), - path: z.string().nullable(), - method: z.string().nullable(), - status: z.number().nullable(), - outcome: z.string(), - eventType: z.string(), - duration: z.number().nullable(), - error: z.string().nullable(), - message: z.string().nullable(), - requestId: z.string(), - rayId: z.string().nullable(), - exceptionStack: z.string().nullable(), -}) -type RelevantLogInfo = z.infer - -const TelemetryKeySchema = z.object({ - key: z.string(), - type: z.enum(['string', 'number', 'boolean']), - lastSeen: z.number().optional(), -}) -type TelemetryKey = z.infer - -const LogsKeysResponseSchema = V4Schema(z.array(TelemetryKeySchema).optional().default([])) - -const WorkerRequestSchema = z.object({ - url: z.string().optional(), - method: z.string().optional(), - path: z.string().optional(), - search: z.record(z.string()).optional(), -}) - -const WorkerResponseSchema = z.object({ - status: z.number().optional(), -}) - -const WorkerEventDetailsSchema = z.object({ - request: WorkerRequestSchema.optional(), - response: WorkerResponseSchema.optional(), - rpcMethod: z.string().optional(), - rayId: z.string().optional(), - executionModel: z.string().optional(), -}) - -const WorkerInfoSchema = z.object({ - scriptName: z.string(), - outcome: z.string(), - eventType: z.string(), - requestId: z.string(), - event: WorkerEventDetailsSchema.optional(), - wallTimeMs: z.number().optional(), - cpuTimeMs: z.number().optional(), - executionModel: z.string().optional(), -}) - -const WorkerSourceSchema = z.object({ - exception: z - .object({ - stack: z.string().optional(), - name: z.string().optional(), - message: z.string().optional(), - timestamp: z.number().optional(), - }) - .optional(), -}) - -type WorkerEventType = z.infer -const WorkerEventSchema = z.object({ - $workers: WorkerInfoSchema.optional(), - timestamp: z.number(), - source: WorkerSourceSchema, - dataset: z.string(), - $metadata: z.object({ - id: z.string(), - message: z.string().optional(), - trigger: z.string().optional(), - error: z.string().optional(), - }), -}) - -const LogsEventsSchema = z.object({ - events: z.array(WorkerEventSchema).optional().default([]), -}) - -const LogsResponseSchema = V4Schema( - z - .object({ - events: LogsEventsSchema.optional().default({ events: [] }), - }) - .optional() - .default({ events: { events: [] } }) -) - -/** - * Extracts only the most relevant information from a worker log event - * @param event The raw worker log event - * @returns Relevant information extracted from the log - */ -function extractRelevantLogInfo(event: WorkerEventType): RelevantLogInfo { - const workers = event.$workers - const metadata = event.$metadata - const source = event.source - - let path = null - let method = null - let status = null - - if (workers?.event?.request) { - path = workers.event.request.path ?? null - method = workers.event.request.method ?? null - } - - if (workers?.event?.response) { - status = workers.event.response.status ?? null - } - - let error = null - if (metadata.error) { - error = metadata.error - } - - let message = metadata?.message ?? null - if (!message) { - if (workers?.event?.rpcMethod) { - message = `RPC: ${workers.event.rpcMethod}` - } else if (path && method) { - message = `${method} ${path}` - } - } - - // Calculate duration - const duration = (workers?.wallTimeMs || 0) + (workers?.cpuTimeMs || 0) - - // Extract rayId if available - const rayId = workers?.event?.rayId ?? null - - // Extract exception stack if available - const exceptionStack = source?.exception?.stack ?? null - - return { - timestamp: new Date(event.timestamp).toISOString(), - path, - method, - status, - outcome: workers?.outcome || 'unknown', - eventType: workers?.eventType || 'unknown', - duration: duration || null, - error, - message, - requestId: workers?.requestId || metadata?.id || 'unknown', - rayId, - exceptionStack, - } -} - -/** - * Fetches recent logs for a specified Cloudflare Worker - * @param scriptName Name of the worker script to get logs for - * @param accountId Cloudflare account ID - * @param apiToken Cloudflare API token - * @returns The logs analysis result with filtered relevant information - */ -export async function handleWorkerLogs({ - limit, - minutesAgo, - accountId, - apiToken, - shouldFilterErrors, - scriptName, - rayId, -}: { - limit: number - minutesAgo: number - accountId: string - apiToken: string - shouldFilterErrors: boolean - scriptName?: string - rayId?: string -}): Promise<{ relevantLogs: RelevantLogInfo[]; from: number; to: number }> { - if (scriptName === undefined && rayId === undefined) { - throw new Error('Either scriptName or rayId must be provided') - } - // Calculate timeframe based on minutesAgo parameter - const now = Date.now() - const fromTimestamp = now - minutesAgo * 60 * 1000 - - type QueryFilter = { id: string; key: string; type: string; operation: string; value?: string } - const filters: QueryFilter[] = [] - - // Build query to fetch logs - if (scriptName) { - filters.push({ - id: 'worker-name-filter', - key: '$metadata.service', - type: 'string', - value: scriptName, - operation: 'eq', - }) - } - - if (shouldFilterErrors === true) { - filters.push({ - id: 'error-filter', - key: '$metadata.error', - type: 'string', - operation: 'exists', - }) - } - - // Add Ray ID filter if provided - if (rayId) { - filters.push({ - id: 'ray-id-filter', - key: '$workers.event.rayId', - type: 'string', - value: rayId, - operation: 'eq', - }) - } - - const queryPayload = { - queryId: 'workers-logs', - timeframe: { - from: fromTimestamp, - to: now, - }, - parameters: { - datasets: ['cloudflare-workers'], - filters, - calculations: [], - groupBys: [], - havings: [], - }, - view: 'events', - limit, - } - - const data = await fetchCloudflareApi({ - endpoint: '/workers/observability/telemetry/query', - accountId, - apiToken, - responseSchema: LogsResponseSchema, - options: { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(queryPayload), - }, - }) - - const events = data.result?.events?.events || [] - - // Extract relevant information from each event - const relevantLogs = events.map(extractRelevantLogInfo) - - return { relevantLogs, from: fromTimestamp, to: now } -} - -/** - * Fetches available telemetry keys for a specified Cloudflare Worker - * @param scriptName Name of the worker script to get keys for - * @param accountId Cloudflare account ID - * @param apiToken Cloudflare API token - * @returns List of telemetry keys available for the worker - */ -export async function handleWorkerLogsKeys( - scriptName: string, - minutesAgo: number, - accountId: string, - apiToken: string -): Promise { - // Calculate timeframe (last 24 hours to ensure we get all keys) - const now = Date.now() - const fromTimestamp = now - minutesAgo * 60 * 1000 - - // Build query for telemetry keys - const queryPayload = { - queryId: 'workers-keys', - timeframe: { - from: fromTimestamp, - to: now, - }, - parameters: { - datasets: ['cloudflare-workers'], - filters: [ - { - id: 'service-filter', - key: '$metadata.service', - type: 'string', - value: `${scriptName}`, - operation: 'eq', - }, - ], - }, - } - - const data = await fetchCloudflareApi({ - endpoint: '/workers/observability/telemetry/keys', - accountId, - apiToken, - responseSchema: LogsKeysResponseSchema, - options: { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'portal-version': '2', - }, - body: JSON.stringify(queryPayload), - }, - }) - - return data.result || [] -} diff --git a/packages/mcp-common/src/api/workers-logs.ts b/packages/mcp-common/src/api/workers-logs.ts new file mode 100644 index 00000000..dd964157 --- /dev/null +++ b/packages/mcp-common/src/api/workers-logs.ts @@ -0,0 +1,158 @@ +import { fetchCloudflareApi } from '../cloudflare-api' +import { zKeysResponse, zReturnedQueryRunResult } from '../schemas/workers-logs-schemas' +import { V4Schema } from '../v4-api' + +/** + * Fetches recent logs for a specified Cloudflare Worker + * @param scriptName Name of the worker script to get logs for + * @param accountId Cloudflare account ID + * @param apiToken Cloudflare API token + * @returns The logs analysis result with filtered relevant information + */ +export async function handleWorkerLogs({ + limit, + minutesAgo, + accountId, + apiToken, + shouldFilterErrors, + scriptName, + rayId, +}: { + limit: number + minutesAgo: number + accountId: string + apiToken: string + shouldFilterErrors: boolean + scriptName?: string + rayId?: string +}): Promise<{ logs: zReturnedQueryRunResult | null; from: number; to: number }> { + if (scriptName === undefined && rayId === undefined) { + throw new Error('Either scriptName or rayId must be provided') + } + // Calculate timeframe based on minutesAgo parameter + const now = Date.now() + const fromTimestamp = now - minutesAgo * 60 * 1000 + + type QueryFilter = { id: string; key: string; type: string; operation: string; value?: string } + const filters: QueryFilter[] = [] + + // Build query to fetch logs + if (scriptName) { + filters.push({ + id: 'worker-name-filter', + key: '$metadata.service', + type: 'string', + value: scriptName, + operation: 'eq', + }) + } + + if (shouldFilterErrors === true) { + filters.push({ + id: 'error-filter', + key: '$metadata.error', + type: 'string', + operation: 'exists', + }) + } + + // Add Ray ID filter if provided + if (rayId) { + filters.push({ + id: 'ray-id-filter', + key: '$workers.event.rayId', + type: 'string', + value: rayId, + operation: 'eq', + }) + } + + const queryPayload = { + queryId: 'workers-logs', + timeframe: { + from: fromTimestamp, + to: now, + }, + parameters: { + datasets: ['cloudflare-workers'], + filters, + calculations: [], + groupBys: [], + havings: [], + }, + view: 'events', + limit, + } + + const data = await fetchCloudflareApi({ + endpoint: '/workers/observability/telemetry/query', + accountId, + apiToken, + responseSchema: V4Schema(zReturnedQueryRunResult), + options: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(queryPayload), + }, + }) + + return { logs: data.result, from: fromTimestamp, to: now } +} + +/** + * Fetches available telemetry keys for a specified Cloudflare Worker + * @param scriptName Name of the worker script to get keys for + * @param accountId Cloudflare account ID + * @param apiToken Cloudflare API token + * @returns List of telemetry keys available for the worker + */ +export async function handleWorkerLogsKeys( + scriptName: string, + minutesAgo: number, + accountId: string, + apiToken: string +): Promise { + // Calculate timeframe (last 24 hours to ensure we get all keys) + const now = Date.now() + const fromTimestamp = now - minutesAgo * 60 * 1000 + + // Build query for telemetry keys + const queryPayload = { + queryId: 'workers-keys', + timeframe: { + from: fromTimestamp, + to: now, + }, + parameters: { + datasets: ['cloudflare-workers'], + filters: [ + { + id: 'service-filter', + key: '$metadata.service', + type: 'string', + value: `${scriptName}`, + operation: 'eq', + }, + ], + }, + } + + const data = await fetchCloudflareApi({ + endpoint: '/workers/observability/telemetry/keys', + accountId, + apiToken, + responseSchema: V4Schema(zKeysResponse), + options: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'portal-version': '2', + }, + body: JSON.stringify(queryPayload), + }, + }) + + return data.result || [] +} diff --git a/packages/mcp-common/src/workers-logs-schemas.ts b/packages/mcp-common/src/schemas/workers-logs-schemas.ts similarity index 76% rename from packages/mcp-common/src/workers-logs-schemas.ts rename to packages/mcp-common/src/schemas/workers-logs-schemas.ts index a42dd8d4..9e0ee363 100644 --- a/packages/mcp-common/src/workers-logs-schemas.ts +++ b/packages/mcp-common/src/schemas/workers-logs-schemas.ts @@ -1,88 +1,86 @@ -import { z } from "zod"; +import { z } from 'zod' -export const numericalOperations = ["eq", "neq", "gt", "gte", "lt", "lte"] as const; +export const numericalOperations = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'] as const export const queryOperations = [ // applies only to strings - "includes", - "not_includes", + 'includes', + 'not_includes', // string operations - "starts_with", - "regex", + 'starts_with', + 'regex', // existence check - "exists", - "is_null", + 'exists', + 'is_null', // right hand side must be a string with comma separated values - "in", - "not_in", + 'in', + 'not_in', // numerica ...numericalOperations, -] as const; +] as const export const queryOperators = [ - "uniq", - "count", - "max", - "min", - "sum", - "avg", - "median", - "p001", - "p01", - "p05", - "p10", - "p25", - "p75", - "p90", - "p95", - "p99", - "p999", - "stddev", - "variance", -] as const; + 'uniq', + 'count', + 'max', + 'min', + 'sum', + 'avg', + 'median', + 'p001', + 'p01', + 'p05', + 'p10', + 'p25', + 'p75', + 'p90', + 'p95', + 'p99', + 'p999', + 'stddev', + 'variance', +] as const -export const zQueryOperator = z.enum(queryOperators); -export const zQueryOperation = z.enum(queryOperations); -export const zQueryNumericalOperations = z.enum(numericalOperations); +export const zQueryOperator = z.enum(queryOperators) +export const zQueryOperation = z.enum(queryOperations) +export const zQueryNumericalOperations = z.enum(numericalOperations) -export const zOffsetDirection = z.enum(["next", "prev"]); -export const zFilterCombination = z.enum(["and", "or", "AND", "OR"]); +export const zOffsetDirection = z.enum(['next', 'prev']) +export const zFilterCombination = z.enum(['and', 'or', 'AND', 'OR']) -export const zPrimitiveUnion = z.union([z.string(), z.number(), z.boolean()]); +export const zPrimitiveUnion = z.union([z.string(), z.number(), z.boolean()]) export const zQueryFilter = z.object({ key: z.string(), operation: zQueryOperation, value: zPrimitiveUnion.optional(), - type: z.enum(["string", "number", "boolean"]), -}); + type: z.enum(['string', 'number', 'boolean']), +}) export const zQueryCalculation = z.object({ key: z.string().optional(), - keyType: z.enum(["string", "number", "boolean"]).optional(), + keyType: z.enum(['string', 'number', 'boolean']).optional(), operator: zQueryOperator, alias: z.string().optional(), -}); +}) export const zQueryGroupBy = z.object({ - type: z.enum(["string", "number", "boolean"]), + type: z.enum(['string', 'number', 'boolean']), value: z.string(), -}); +}) export const zSearchNeedle = z.object({ value: zPrimitiveUnion, isRegex: z.boolean().optional(), matchCase: z.boolean().optional(), -}); +}) const zViews = z - .enum(["traces", "events", "calculations", "invocations", "requests", "patterns"]) - .optional(); - - + .enum(['traces', 'events', 'calculations', 'invocations', 'requests', 'patterns']) + .optional() export const zAggregateResult = z.object({ groups: z.array(z.object({ key: z.string(), value: zPrimitiveUnion })).optional(), @@ -90,13 +88,13 @@ export const zAggregateResult = z.object({ count: z.number(), interval: z.number(), sampleInterval: z.number(), -}); +}) export const zQueryRunCalculationsV2 = z.array( z.object({ alias: z .string() - .transform((val) => (val === "" ? undefined : val)) + .transform((val) => (val === '' ? undefined : val)) .optional(), calculation: z.string(), aggregates: z.array(zAggregateResult), @@ -104,36 +102,34 @@ export const zQueryRunCalculationsV2 = z.array( z.object({ time: z.string(), data: z.array( - zAggregateResult.merge( - z.object({ firstSeen: z.string(), lastSeen: z.string() }), - ), + zAggregateResult.merge(z.object({ firstSeen: z.string(), lastSeen: z.string() })) ), - }), + }) ), - }), -); + }) +) export const zStatistics = z.object({ elapsed: z.number(), rows_read: z.number(), bytes_read: z.number(), -}); +}) export const zCloudflareMiniEvent = z.object({ event: z.record(z.string(), z.unknown()).optional(), scriptName: z.string(), outcome: z.string(), eventType: z.enum([ - "fetch", - "scheduled", - "alarm", - "cron", - "queue", - "email", - "tail", - "rpc", - "websocket", - "unknown", + 'fetch', + 'scheduled', + 'alarm', + 'cron', + 'queue', + 'email', + 'tail', + 'rpc', + 'websocket', + 'unknown', ]), entrypoint: z.string().optional(), scriptVersion: z @@ -144,9 +140,11 @@ export const zCloudflareMiniEvent = z.object({ }) .optional(), truncated: z.boolean().optional(), - executionModel: z.enum(["durableObject", "stateless"]).optional(), + executionModel: z.enum(['durableObject', 'stateless']).optional(), requestId: z.string(), -}); + cpuTimeMs: z.number().optional(), + wallTimeMs: z.number().optional(), +}) export const zCloudflareEvent = zCloudflareMiniEvent.extend({ diagnosticsChannelEvents: z @@ -155,13 +153,13 @@ export const zCloudflareEvent = zCloudflareMiniEvent.extend({ timestamp: z.number(), channel: z.string(), message: z.string(), - }), + }) ) .optional(), dispatchNamespace: z.string().optional(), wallTimeMs: z.number(), cpuTimeMs: z.number(), -}); +}) export const zReturnedTelemetryEvent = z.object({ dataset: z.string(), @@ -198,7 +196,7 @@ export const zReturnedTelemetryEvent = z.object({ messageTemplate: z.string().optional(), errorTemplate: z.string().optional(), }), -}); +}) export const zReturnedQueryRunEvents = z.object({ events: z.array(zReturnedTelemetryEvent).optional(), @@ -207,16 +205,17 @@ export const zReturnedQueryRunEvents = z.object({ z.object({ key: z.string(), type: z.string(), - }), + }) ) .optional(), count: z.number().optional(), -}); +}) /** * The request to run a query */ export const zQueryRunRequest = z.object({ + // TODO: Fix these types queryId: z.string(), parameters: z.object({ datasets: z.array(z.string()).optional(), @@ -227,7 +226,7 @@ export const zQueryRunRequest = z.object({ orderBy: z .object({ value: z.string(), - order: z.enum(["asc", "desc"]).optional(), + order: z.enum(['asc', 'desc']).optional(), }) .optional(), limit: z.number().int().nonnegative().max(100).optional(), @@ -239,24 +238,25 @@ export const zQueryRunRequest = z.object({ }), granularity: z.number().optional(), limit: z.number().max(100).optional().default(50), - view: zViews.optional().default("calculations"), + view: zViews.optional().default('calculations'), dry: z.boolean().optional().default(false), offset: z.string().optional(), offsetBy: z.number().optional(), offsetDirection: z.string().optional(), -}); +}) /** * The response from the API */ +export type zReturnedQueryRunResult = z.infer export const zReturnedQueryRunResult = z.object({ - run: zQueryRunRequest, + // run: zQueryRunRequest, calculations: zQueryRunCalculationsV2.optional(), compare: zQueryRunCalculationsV2.optional(), events: zReturnedQueryRunEvents.optional(), invocations: z.record(z.string(), z.array(zReturnedTelemetryEvent)).optional(), statistics: zStatistics, -}); +}) /** * Keys Request @@ -273,18 +273,19 @@ export const zKeysRequest = z.object({ limit: z.number().optional(), needle: zSearchNeedle.optional(), keyNeedle: zSearchNeedle.optional(), -}); +}) /** * Keys Response */ +export type zKeysResponse = z.infer export const zKeysResponse = z.array( z.object({ key: z.string(), - type: z.enum(["string", "boolean", "number"]), + type: z.enum(['string', 'boolean', 'number']), lastSeenAt: z.number(), - }), -); + }) +) /** * Values Request @@ -295,19 +296,19 @@ export const zValuesRequest = z.object({ from: z.number(), }), key: z.string(), - type: z.enum(["string", "boolean", "number"]), + type: z.enum(['string', 'boolean', 'number']), datasets: z.array(z.string()), filters: z.array(zQueryFilter).default([]), limit: z.number().default(50), needle: zSearchNeedle.optional(), -}); +}) /** Values Response */ export const zValuesResponse = z.array( z.object({ key: z.string(), - type: z.enum(["string", "boolean", "number"]), + type: z.enum(['string', 'boolean', 'number']), value: z.union([z.string(), z.number(), z.boolean()]), dataset: z.string(), - }), -); + }) +) diff --git a/packages/mcp-common/tests/logs.spec.ts b/packages/mcp-common/tests/logs.spec.ts index 21362bd1..367738c3 100644 --- a/packages/mcp-common/tests/logs.spec.ts +++ b/packages/mcp-common/tests/logs.spec.ts @@ -1,8 +1,8 @@ import { env, fetchMock } from 'cloudflare:test' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { handleWorkerLogs, handleWorkerLogsKeys } from '../src/api/logs' -import { cloudflareClientMockImplementation } from '../src/utils/cloudflare-mock' +import { handleWorkerLogs, handleWorkerLogsKeys } from '../src/api/workers-logs' +import { cloudflareClientMockImplementation } from './utils/cloudflare-mock' beforeAll(() => { vi.mock('cloudflare', () => { @@ -122,6 +122,12 @@ describe('Logs API', () => { result: { events: { events: mockEvents, + count: 3, + }, + statistics: { + elapsed: 10, + rows_read: 6000, + bytes_read: 30000000, }, }, errors: [], @@ -152,29 +158,29 @@ describe('Logs API', () => { expect(result).toHaveProperty('to') expect(result.from).toBeLessThan(result.to) - // Verify that we have relevant logs that match our mock data - expect(result.relevantLogs).toHaveLength(3) - - const getLog = result.relevantLogs[0] - expect(getLog.method).toBe(mockEvents[0].$workers.event.request.method) - expect(getLog.path).toBe(mockEvents[0].$workers.event.request.path) - expect(getLog.status).toBe(mockEvents[0].$workers.event.response?.status) - expect(getLog.outcome).toBe(mockEvents[0].$workers.outcome) - expect(getLog.rayId).toBe(mockEvents[0].$workers.event.rayId) - expect(getLog.duration).toBeGreaterThan(0) - - const postLog = result.relevantLogs[1] - expect(postLog.method).toBe(mockEvents[1].$workers.event.request.method) - expect(postLog.path).toBe(mockEvents[1].$workers.event.request.path) - expect(postLog.status).toBe(mockEvents[1].$workers.event.response?.status) - expect(postLog.outcome).toBe(mockEvents[1].$workers.outcome) - expect(postLog.rayId).toBe(mockEvents[1].$workers.event.rayId) - - const errorLog = result.relevantLogs[2] - expect(errorLog.method).toBe(mockEvents[2].$workers.event.request.method) - expect(errorLog.path).toBe(mockEvents[2].$workers.event.request.path) - expect(errorLog.outcome).toBe(mockEvents[2].$workers.outcome) - expect(errorLog.rayId).toBe(mockEvents[2].$workers.event.rayId) + expect(result.logs?.events?.count).toBe(3) + + const events = result.logs?.events?.events ?? [] + const getLog = events[0] + expect(getLog.$workers?.event?.request).toStrictEqual(mockEvents[0].$workers.event.request) + expect(getLog.$workers?.outcome).toBe(mockEvents[0].$workers.outcome) + expect(getLog.$workers?.event?.rayId).toBe(mockEvents[0].$workers.event.rayId) + expect(getLog.$workers?.cpuTimeMs).toBeGreaterThan(0) + expect(getLog.$workers?.wallTimeMs).toBeGreaterThan(0) + + const postLog = events[1] + expect(postLog.$workers?.event?.request).toStrictEqual(mockEvents[1].$workers.event.request) + expect(postLog.$workers?.outcome).toBe(mockEvents[1].$workers.outcome) + expect(postLog.$workers?.event?.rayId).toBe(mockEvents[1].$workers.event.rayId) + expect(postLog.$workers?.cpuTimeMs).toBeGreaterThan(0) + expect(postLog.$workers?.wallTimeMs).toBeGreaterThan(0) + + const errorLog = events[2] + expect(errorLog.$workers?.event?.request).toStrictEqual(mockEvents[2].$workers.event.request) + expect(errorLog.$workers?.outcome).toBe(mockEvents[2].$workers.outcome) + expect(errorLog.$workers?.event?.rayId).toBe(mockEvents[2].$workers.event.rayId) + expect(errorLog.$workers?.cpuTimeMs).toBeGreaterThan(0) + expect(errorLog.$workers?.wallTimeMs).toBeGreaterThan(0) }) it('should handle empty logs', async () => { @@ -185,6 +191,12 @@ describe('Logs API', () => { result: { events: { events: [], + count: 0, + }, + statistics: { + elapsed: 10, + rows_read: 6000, + bytes_read: 30000000, }, }, errors: [], @@ -210,7 +222,7 @@ describe('Logs API', () => { shouldFilterErrors: false, }) - expect(result.relevantLogs.length).toBe(0) + expect(result.logs?.events?.count).toBe(0) }) it('should handle API errors', async () => { @@ -322,6 +334,12 @@ describe('Logs API', () => { result: { events: { events: mockEvents.filter((event) => event.$workers.outcome === 'error'), + count: 3, + }, + statistics: { + elapsed: 10, + rows_read: 6000, + bytes_read: 30000000, }, }, errors: [], @@ -349,18 +367,23 @@ describe('Logs API', () => { }) // Check results - we should only get error logs - expect(result.relevantLogs.filter((log) => log.outcome === 'error').length).toBe(2) - expect(result.relevantLogs.length).toBe(2) + expect( + result.logs?.events?.events?.filter((event) => event.$workers?.outcome == 'error').length + ).toBe(2) + expect(result.logs?.events?.count).toBe(3) - const firstErrorLog = result.relevantLogs.find((log) => log.error === 'Invalid request data') + const firstErrorLog = result.logs?.events?.events?.find( + (event) => event.$metadata?.error === 'Invalid request data' + ) + console.log(firstErrorLog) expect(firstErrorLog).toBeDefined() - expect(firstErrorLog?.method).toBe('POST') - expect(firstErrorLog?.rayId).toBe('ray456def789ghi') + expect(firstErrorLog?.$workers?.event?.rayId).toBe('ray456def789ghi') - const secondErrorLog = result.relevantLogs.find((log) => log.error === 'Resource not found') + const secondErrorLog = result.logs?.events?.events?.find( + (event) => event.$metadata?.error === 'Resource not found' + ) expect(secondErrorLog).toBeDefined() - expect(secondErrorLog?.method).toBe('PUT') - expect(secondErrorLog?.rayId).toBe('ray789ghi012jkl') + expect(secondErrorLog?.$workers?.event?.rayId).toBe('ray789ghi012jkl') }) }) @@ -375,17 +398,17 @@ describe('Logs API', () => { { key: '$workers.outcome', type: 'string', - lastSeen: Date.now() - 1000000, + lastSeenAt: Date.now() - 1000000, }, { key: '$workers.wallTimeMs', type: 'number', - lastSeen: Date.now() - 2000000, + lastSeenAt: Date.now() - 2000000, }, { key: '$workers.event.error', type: 'boolean', - lastSeen: Date.now() - 3000000, + lastSeenAt: Date.now() - 3000000, }, ], errors: [], @@ -400,7 +423,7 @@ describe('Logs API', () => { }) .reply(200, mockKeysResponse) - const minutesAgo = 1440 + const minutesAgo = 10080 const result = await handleWorkerLogsKeys( scriptName, minutesAgo, @@ -430,7 +453,7 @@ describe('Logs API', () => { }) .reply(500, 'Server error') - const minutesAgo = 1440 + const minutesAgo = 10080 await expect( handleWorkerLogsKeys( scriptName, diff --git a/packages/mcp-common/src/utils/cloudflare-mock.ts b/packages/mcp-common/tests/utils/cloudflare-mock.ts similarity index 100% rename from packages/mcp-common/src/utils/cloudflare-mock.ts rename to packages/mcp-common/tests/utils/cloudflare-mock.ts