diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index a10c0acf1f9..efedba2000d 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -8758,3 +8758,21 @@ export function RocketlaneIcon(props: SVGProps) { ) } + +/** + * Pydantic Logfire. Single-fill mark drawn with `fill='currentColor'` so it + * takes white on its brand tile and the block's `iconColor` when rendered bare. + */ +export function LogfireIcon(props: SVGProps) { + return ( + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index df939ac6f77..8070694868b 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -133,6 +133,7 @@ import { LinkedInIcon, LinkupIcon, LinqIcon, + LogfireIcon, LoopsIcon, LumaIcon, MailchimpIcon, @@ -403,6 +404,7 @@ export const blockTypeToIconMap: Record = { linkedin: LinkedInIcon, linkup: LinkupIcon, linq: LinqIcon, + logfire: LogfireIcon, logs: Library, logs_v2: Library, loops: LoopsIcon, diff --git a/apps/docs/content/docs/en/integrations/logfire.mdx b/apps/docs/content/docs/en/integrations/logfire.mdx new file mode 100644 index 00000000000..1ab38b081aa --- /dev/null +++ b/apps/docs/content/docs/en/integrations/logfire.mdx @@ -0,0 +1,168 @@ +--- +title: Logfire +description: Query traces, logs, and metrics in Pydantic Logfire +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Pydantic Logfire](https://pydantic.dev/logfire) is an observability platform built on OpenTelemetry. It collects the traces, logs, and metrics your services emit, stores every span in a queryable `records` table, and exposes that table through a read-only SQL API. + +With Logfire, you can: + +- **Search spans and logs**: Filter by message text, service, span name, severity, deployment environment, and whether an exception was recorded — no SQL required. +- **Run SQL directly**: Query the `records` and `metrics` tables with PostgreSQL-compatible syntax for aggregations like error rates and latency percentiles. +- **Reconstruct a request**: Pull every span in a trace, ordered earliest to latest, and walk the parent-child tree to see where time went and where it failed. +- **Confirm a credential**: Resolve which organization and project a read token targets before querying it. + +Sim's Logfire integration lets agents read production telemetry as part of a run. Use it to triage errors against a live service, attach a root-cause summary to an incident ticket, or watch latency between deploys and page when it regresses. + +Authentication uses a Logfire **read token**, which you create per project under Settings → Read tokens. The region is detected from the token's prefix, so Cloud users on US and EU need no extra configuration. Self-hosted instances set the Host field to their own base URL, which must be reachable over HTTPS at a publicly resolvable domain. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Pydantic Logfire into workflows. Run SQL over your observability data, search spans and logs with structured filters, pull an entire trace by ID, and confirm which project a read token targets. + + + +## Actions + +### `logfire_query` + +Run a read-only SQL query against Logfire traces, logs, and metrics. Reads the records and metrics tables using PostgreSQL-compatible syntax. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Logfire read token | +| `region` | string | No | Logfire data region: auto, us, or eu. Auto reads the region from the token. | +| `host` | string | No | Base URL of a self-hosted Logfire instance, e.g. https://logfire.example.com. Overrides region. Leave blank for Logfire Cloud. | +| `sql` | string | Yes | SQL SELECT query to run against the records or metrics table | +| `minTimestamp` | string | No | ISO 8601 lower bound on start_timestamp. Defaults to 2020-01-01T00:00:00Z when omitted. | +| `maxTimestamp` | string | No | ISO 8601 upper bound on start_timestamp | +| `limit` | number | No | Maximum rows to return. Logfire defaults to 100 and caps at 10000. | +| `timezone` | string | No | IANA timezone used to evaluate the query, for example Europe/Paris | +| `environment` | string | No | Restrict results to a single deployment environment | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Result rows. Row fields depend on the query projection. | +| `columns` | array | Column metadata for the result set | +| ↳ `name` | string | Column name | +| ↳ `datatype` | json | Arrow datatype of the column | +| ↳ `nullable` | boolean | Whether the column is nullable | +| `rowCount` | number | Number of rows returned | + +### `logfire_search_records` + +Search Logfire spans and logs using structured filters for message text, service, span name, severity, environment, and exceptions. Returns the most recent matches first without writing SQL. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Logfire read token | +| `region` | string | No | Logfire data region: auto, us, or eu. Auto reads the region from the token. | +| `host` | string | No | Base URL of a self-hosted Logfire instance, e.g. https://logfire.example.com. Overrides region. Leave blank for Logfire Cloud. | +| `query` | string | No | Case-insensitive text to match within the record message | +| `service` | string | No | Exact service name to filter on | +| `spanName` | string | No | Exact span name to filter on | +| `minLevel` | string | No | Minimum severity to include: trace, debug, info, notice, warn, error, or fatal | +| `exceptionsOnly` | boolean | No | Only return records that recorded an exception | +| `environment` | string | No | Restrict results to a single deployment environment | +| `minTimestamp` | string | No | ISO 8601 lower bound on start_timestamp. Defaults to 2020-01-01T00:00:00Z when omitted. | +| `maxTimestamp` | string | No | ISO 8601 upper bound on start_timestamp | +| `limit` | number | No | Maximum records to return. Logfire defaults to 100 and caps at 10000. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Matching spans and logs, most recent first | +| ↳ `startTimestamp` | string | UTC time the span started | +| ↳ `endTimestamp` | string | UTC time the span ended | +| ↳ `duration` | number | Span duration in seconds. Null for logs. | +| ↳ `level` | string | Severity name, such as info, warn, or error | +| ↳ `message` | string | Human-readable message | +| ↳ `spanName` | string | Template label for similar records | +| ↳ `kind` | string | Record kind: span, log, or span_event | +| ↳ `serviceName` | string | Service that emitted the record | +| ↳ `deploymentEnvironment` | string | Deployment environment of the record | +| ↳ `traceId` | string | Trace this record belongs to | +| ↳ `spanId` | string | Identifier of this span | +| ↳ `parentSpanId` | string | Parent span identifier | +| ↳ `isException` | boolean | Whether an exception was recorded on the span | +| ↳ `exceptionType` | string | Fully qualified exception class name | +| ↳ `exceptionMessage` | string | Exception message | +| `rowCount` | number | Number of rows returned | +| `sql` | string | SQL query that was executed against Logfire | + +### `logfire_get_trace` + +Fetch every span and log belonging to a Logfire trace, ordered from earliest to latest, so a single request can be reconstructed end to end. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Logfire read token | +| `region` | string | No | Logfire data region: auto, us, or eu. Auto reads the region from the token. | +| `host` | string | No | Base URL of a self-hosted Logfire instance, e.g. https://logfire.example.com. Overrides region. Leave blank for Logfire Cloud. | +| `traceId` | string | Yes | 32-character hexadecimal trace identifier | +| `minTimestamp` | string | No | ISO 8601 lower bound on start_timestamp. Defaults to 2020-01-01T00:00:00Z when omitted. | +| `maxTimestamp` | string | No | ISO 8601 upper bound on start_timestamp | +| `limit` | number | No | Maximum spans to return. Logfire defaults to 100 and caps at 10000. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Spans and logs in the trace, earliest first | +| ↳ `startTimestamp` | string | UTC time the span started | +| ↳ `endTimestamp` | string | UTC time the span ended | +| ↳ `duration` | number | Span duration in seconds. Null for logs. | +| ↳ `level` | string | Severity name, such as info, warn, or error | +| ↳ `message` | string | Human-readable message | +| ↳ `spanName` | string | Template label for similar records | +| ↳ `kind` | string | Record kind: span, log, or span_event | +| ↳ `serviceName` | string | Service that emitted the record | +| ↳ `deploymentEnvironment` | string | Deployment environment of the record | +| ↳ `traceId` | string | Trace this record belongs to | +| ↳ `spanId` | string | Identifier of this span | +| ↳ `parentSpanId` | string | Parent span identifier | +| ↳ `isException` | boolean | Whether an exception was recorded on the span | +| ↳ `exceptionType` | string | Fully qualified exception class name | +| ↳ `exceptionMessage` | string | Exception message | +| `rowCount` | number | Number of rows returned | +| `sql` | string | SQL query that was executed against Logfire | + +### `logfire_get_token_info` + +Resolve which Logfire organization and project a read token belongs to. Useful for confirming a credential targets the expected project before querying it. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Logfire read token | +| `region` | string | No | Logfire data region: auto, us, or eu. Auto reads the region from the token. | +| `host` | string | No | Base URL of a self-hosted Logfire instance, e.g. https://logfire.example.com. Overrides region. Leave blank for Logfire Cloud. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `organizationName` | string | Logfire organization the read token belongs to | +| `projectName` | string | Logfire project the read token belongs to | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 853e1f180d5..56235d7d9c2 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -140,6 +140,7 @@ "linkedin", "linkup", "linq", + "logfire", "logs", "loops", "luma", diff --git a/apps/sim/blocks/blocks/logfire.ts b/apps/sim/blocks/blocks/logfire.ts new file mode 100644 index 00000000000..fa8b17c0d00 --- /dev/null +++ b/apps/sim/blocks/blocks/logfire.ts @@ -0,0 +1,471 @@ +import { Bug, ClipboardList, Clock, Database, File, Search, Server } from '@sim/emcn/icons' +import { LogfireIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { LogfireResponse } from '@/tools/logfire/types' + +const toBoolean = (value: unknown): boolean | undefined => { + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + return undefined +} + +const toNumber = (value: unknown): number | undefined => { + if (value === undefined || value === null || value === '') return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +const LOGFIRE_QUERY_PROMPT = `You are an expert Pydantic Logfire analyst. Write read-only SQL SELECT queries against Logfire observability data based on the user's request. + +### CONTEXT +{context} + +### CRITICAL INSTRUCTION +Return ONLY the SQL query. Do not include any explanations, markdown formatting, comments, or additional text. Just the raw SQL query. + +### QUERY GUIDELINES +1. **Syntax**: Logfire runs Apache DataFusion with PostgreSQL-compatible syntax +2. **Tables**: \`records\` holds one row per span or log and is what you almost always want; \`metrics\` holds pre-aggregated numeric data +3. **Time window**: Do NOT add a start_timestamp filter — the block sends the time window separately as min_timestamp/max_timestamp +4. **Readability**: Format queries with proper indentation and spacing + +### RECORDS COLUMNS +- Identity: span_name, message, kind (span, log, span_event), tags, attributes (JSON) +- Severity: level — stored as an OpenTelemetry severity number but comparable to names, so \`level >= 'error'\` works. Project \`level_name(level)\` for a readable severity +- Span tree: trace_id, span_id, parent_span_id +- Timing: start_timestamp, end_timestamp, duration (seconds, null for logs) +- Exceptions: is_exception, exception_type, exception_message, exception_stacktrace +- Resource: service_name, service_version, deployment_environment, otel_scope_name +- HTTP: http_route, http_method, url_path, url_full, http_response_status_code + +### LOGFIRE FEATURES +- Extract JSON from attributes with the \`->>\` operator, e.g. \`attributes->>'user_id'\` +- Use \`level_num('warn')\` to convert a name to its number, \`level_name(level)\` for the reverse +- Errors that are exceptions: \`WHERE is_exception AND level >= 'error'\` + +### EXAMPLES + +**Recent errors**: "Show me the latest errors per service" +→ SELECT start_timestamp, service_name, level_name(level) AS level, message, exception_type + FROM records + WHERE level >= 'error' + ORDER BY start_timestamp DESC; + +**Latency aggregation**: "What are my slowest operations by p95?" +→ SELECT + span_name, + count(*) AS calls, + approx_percentile_cont(duration, 0.95) AS p95_seconds + FROM records + WHERE duration IS NOT NULL + GROUP BY span_name + ORDER BY p95_seconds DESC; + +**Exception breakdown**: "Group exceptions by type for the checkout service" +→ SELECT exception_type, count(*) AS occurrences + FROM records + WHERE is_exception AND service_name = 'checkout-api' + GROUP BY exception_type + ORDER BY occurrences DESC; + +### REMEMBER +Return ONLY the SQL query - no explanations, no markdown, no extra text.` + +export const LogfireBlock: BlockConfig = { + type: 'logfire', + name: 'Logfire', + description: 'Query traces, logs, and metrics in Pydantic Logfire', + longDescription: + 'Integrate Pydantic Logfire into workflows. Run SQL over your observability data, search spans and logs with structured filters, pull an entire trace by ID, and confirm which project a read token targets.', + docsLink: 'https://docs.sim.ai/integrations/logfire', + category: 'tools', + authMode: AuthMode.ApiKey, + integrationType: IntegrationType.Observability, + bgColor: '#E520E9', + iconColor: '#E520E9', + icon: LogfireIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Search Records', id: 'logfire_search_records' }, + { label: 'Run SQL Query', id: 'logfire_query' }, + { label: 'Get Trace', id: 'logfire_get_trace' }, + { label: 'Get Token Info', id: 'logfire_get_token_info' }, + ], + value: () => 'logfire_search_records', + }, + { + id: 'apiKey', + title: 'Read Token', + type: 'short-input', + placeholder: 'pylf_v1_us_...', + password: true, + required: true, + }, + { + id: 'host', + title: 'Host', + type: 'short-input', + placeholder: 'https://logfire-us.pydantic.dev', + description: + 'Base URL of your Logfire instance. Leave blank for Logfire Cloud — the region is read from your token. Set this for a self-hosted deployment.', + }, + { + id: 'region', + title: 'Region', + type: 'dropdown', + options: [ + { label: 'Auto (from token)', id: 'auto' }, + { label: 'US', id: 'us' }, + { label: 'EU', id: 'eu' }, + ], + value: () => 'auto', + description: 'Cloud region. Ignored when Host is set.', + mode: 'advanced', + }, + { + id: 'sql', + title: 'SQL Query', + type: 'code', + placeholder: "SELECT message, level_name(level) AS level FROM records WHERE level >= 'error'", + condition: { field: 'operation', value: 'logfire_query' }, + required: true, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: LOGFIRE_QUERY_PROMPT, + placeholder: 'Describe the Logfire data you want to query...', + generationType: 'sql-query', + }, + }, + { + id: 'query', + title: 'Message Contains', + type: 'short-input', + placeholder: 'timeout', + condition: { field: 'operation', value: 'logfire_search_records' }, + }, + { + id: 'service', + title: 'Service Name', + type: 'short-input', + placeholder: 'checkout-api', + condition: { field: 'operation', value: 'logfire_search_records' }, + }, + { + id: 'minLevel', + title: 'Minimum Level', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Trace', id: 'trace' }, + { label: 'Debug', id: 'debug' }, + { label: 'Info', id: 'info' }, + { label: 'Notice', id: 'notice' }, + { label: 'Warn', id: 'warn' }, + { label: 'Error', id: 'error' }, + { label: 'Fatal', id: 'fatal' }, + ], + value: () => '', + condition: { field: 'operation', value: 'logfire_search_records' }, + }, + { + id: 'exceptionsOnly', + title: 'Exceptions Only', + type: 'switch', + description: 'Only return records that recorded an exception', + condition: { field: 'operation', value: 'logfire_search_records' }, + }, + { + id: 'spanName', + title: 'Span Name', + type: 'short-input', + placeholder: 'GET /orders/{id}', + condition: { field: 'operation', value: 'logfire_search_records' }, + mode: 'advanced', + }, + { + id: 'traceId', + title: 'Trace ID', + type: 'short-input', + placeholder: '0123456789abcdef0123456789abcdef', + condition: { field: 'operation', value: 'logfire_get_trace' }, + required: true, + }, + { + id: 'environment', + title: 'Deployment Environment', + type: 'short-input', + placeholder: 'production', + condition: { field: 'operation', value: ['logfire_query', 'logfire_search_records'] }, + mode: 'advanced', + }, + { + id: 'minTimestamp', + title: 'Start Time', + type: 'short-input', + placeholder: '2026-07-29T00:00:00Z', + condition: { + field: 'operation', + value: ['logfire_query', 'logfire_search_records', 'logfire_get_trace'], + }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 UTC timestamp for the start of the requested time window. Return ONLY the timestamp string.', + placeholder: 'Describe the start of the time window...', + generationType: 'timestamp', + }, + }, + { + id: 'maxTimestamp', + title: 'End Time', + type: 'short-input', + placeholder: '2026-07-30T00:00:00Z', + condition: { + field: 'operation', + value: ['logfire_query', 'logfire_search_records', 'logfire_get_trace'], + }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 UTC timestamp for the end of the requested time window. Return ONLY the timestamp string.', + placeholder: 'Describe the end of the time window...', + generationType: 'timestamp', + }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '100', + condition: { + field: 'operation', + value: ['logfire_query', 'logfire_search_records', 'logfire_get_trace'], + }, + mode: 'advanced', + }, + { + id: 'timezone', + title: 'Timezone', + type: 'short-input', + placeholder: 'Europe/Paris', + condition: { field: 'operation', value: 'logfire_query' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Convert the user's description into an IANA timezone identifier. + +Examples: +- "New York" or "Eastern" -> America/New_York +- "London" -> Europe/London +- "Paris" -> Europe/Paris +- "Tokyo" -> Asia/Tokyo +- "UTC" or "GMT" -> UTC + +Return ONLY the IANA timezone string - no explanations or quotes.`, + placeholder: 'Describe the timezone (e.g., "New York", "Pacific time")...', + generationType: 'timezone', + }, + }, + ], + tools: { + access: [ + 'logfire_query', + 'logfire_search_records', + 'logfire_get_trace', + 'logfire_get_token_info', + ], + config: { + tool: (params) => String(params.operation || 'logfire_search_records'), + params: (params) => { + const baseParams = { + apiKey: params.apiKey, + region: params.region || 'auto', + host: params.host, + } + + const window = { + minTimestamp: params.minTimestamp, + maxTimestamp: params.maxTimestamp, + limit: toNumber(params.limit), + } + + switch (params.operation) { + case 'logfire_query': + return { + ...baseParams, + ...window, + sql: params.sql, + timezone: params.timezone, + environment: params.environment, + } + + case 'logfire_get_trace': + return { + ...baseParams, + ...window, + traceId: params.traceId, + } + + case 'logfire_get_token_info': + return baseParams + + default: + return { + ...baseParams, + ...window, + query: params.query, + service: params.service, + spanName: params.spanName, + minLevel: params.minLevel || undefined, + exceptionsOnly: toBoolean(params.exceptionsOnly), + environment: params.environment, + } + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Logfire read token' }, + region: { type: 'string', description: 'Logfire data region: auto, us, or eu' }, + host: { type: 'string', description: 'Base URL of a self-hosted Logfire instance' }, + sql: { type: 'string', description: 'SQL SELECT query to run' }, + query: { type: 'string', description: 'Text to match within the record message' }, + service: { type: 'string', description: 'Service name to filter on' }, + spanName: { type: 'string', description: 'Span name to filter on' }, + minLevel: { type: 'string', description: 'Minimum severity level to include' }, + exceptionsOnly: { type: 'boolean', description: 'Only return records with an exception' }, + traceId: { type: 'string', description: 'Trace identifier to fetch' }, + environment: { type: 'string', description: 'Deployment environment to filter on' }, + minTimestamp: { type: 'string', description: 'ISO 8601 start of the query time window' }, + maxTimestamp: { type: 'string', description: 'ISO 8601 end of the query time window' }, + limit: { type: 'number', description: 'Maximum rows to return' }, + timezone: { type: 'string', description: 'IANA timezone used to evaluate the query' }, + }, + outputs: { + rows: { + type: 'json', + description: + 'Result rows. Raw SQL returns the query projection; Search Records and Get Trace return records (startTimestamp, endTimestamp, duration, level, message, spanName, kind, serviceName, deploymentEnvironment, traceId, spanId, parentSpanId, isException, exceptionType, exceptionMessage).', + }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + columns: { type: 'json', description: 'Column metadata (name, datatype, nullable)' }, + sql: { type: 'string', description: 'SQL query that was executed against Logfire' }, + organizationName: { type: 'string', description: 'Organization the read token belongs to' }, + projectName: { type: 'string', description: 'Project the read token belongs to' }, + }, +} + +export const LogfireBlockMeta = { + tags: ['monitoring', 'error-tracking', 'incident-management'], + url: 'https://pydantic.dev/logfire', + templates: [ + { + icon: LogfireIcon, + title: 'Logfire error digest', + prompt: + 'Create a scheduled workflow that runs every morning, searches Logfire for records at error level or above from the last 24 hours, groups them by service and exception type, and posts a ranked digest to Slack with the worst offenders first.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring', 'reporting'], + alsoIntegrations: ['slack'], + }, + { + icon: Search, + title: 'Logfire trace investigator', + prompt: + 'Build a workflow that takes a Logfire trace ID, fetches every span in the trace, walks the parent-child tree to find the slowest span and the first exception, and writes a plain-English root-cause summary back as a Linear comment.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring', 'incident-management'], + alsoIntegrations: ['linear'], + }, + { + icon: Clock, + title: 'Logfire latency watchdog', + prompt: + 'Create a workflow that runs every 15 minutes, uses SQL against the Logfire records table to compute p95 duration per span_name over the window, compares it against the prior window stored in a table, and pages on-call through PagerDuty when latency regresses more than 50 percent.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring', 'incident-management'], + alsoIntegrations: ['pagerduty'], + }, + { + icon: ClipboardList, + title: 'Logfire LLM cost tracker', + prompt: + 'Build a scheduled daily workflow that queries Logfire for spans emitted by LLM instrumentation, extracts token counts from the attributes JSON, aggregates spend per model and per service into a table, and emails finance when a service trends over budget.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'finance', 'reporting'], + alsoIntegrations: ['gmail'], + }, + { + icon: Bug, + title: 'Logfire release regression check', + prompt: + 'Create a workflow that fires after each deploy, waits for a soak period, then searches Logfire for new exception types in the production environment that were not present before the release, and comments the diff on the GitHub pull request that shipped it.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'ci-cd', 'monitoring'], + alsoIntegrations: ['github'], + }, + { + icon: Server, + title: 'Logfire customer issue triage', + prompt: + 'Build a workflow triggered by a support ticket that pulls the customer request ID from the ticket, searches Logfire for records matching that ID, fetches the full trace when one is found, and replies in Zendesk with whether the failure was user error or a backend fault.', + modules: ['agent', 'workflows'], + category: 'support', + tags: ['customer-support', 'monitoring', 'ticketing'], + alsoIntegrations: ['zendesk'], + }, + { + icon: File, + title: 'Logfire weekly reliability review', + prompt: + 'Create a scheduled weekly workflow that runs SQL against Logfire for error rate, throughput, and duration percentiles per service, writes a narrative reliability review file for the SRE team, and highlights every service whose error budget burn accelerated week over week.', + modules: ['scheduled', 'agent', 'files', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring', 'reporting'], + }, + { + icon: Database, + title: 'Logfire agent run auditor', + prompt: + 'Build a workflow that searches Logfire for spans from your AI agent service where an exception was recorded, fetches each full trace to capture the prompt and tool calls that led to the failure, and logs a structured failure catalog to a table for prompt tuning.', + modules: ['tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring', 'agentic'], + }, + ], + skills: [ + { + name: 'investigate-logfire-errors', + description: + 'Find the dominant error patterns in Logfire for a service and time window, then explain them.', + content: + '# Investigate Logfire Errors\n\nFind and explain what is failing in a service.\n\n## Steps\n1. Run Search Records with the service name, a minimum level of error, and the requested time window.\n2. Group the results by exceptionType and message to find the dominant failure patterns.\n3. Pick the most frequent or most severe pattern and run Get Trace on one of its traceId values to see the full request path.\n4. Read the surrounding spans to identify where in the call chain the failure originates.\n\n## Output\nThe top error patterns with counts, the service and span where each originates, and a one-line likely cause per pattern. Include one representative trace ID for each.', + }, + { + name: 'analyze-logfire-latency', + description: 'Use SQL against the Logfire records table to find the slowest operations.', + content: + "# Analyze Logfire Latency\n\nFind what is slow and quantify it.\n\n## Steps\n1. Run a SQL Query aggregating duration over the records table, for example: SELECT span_name, count(*) AS n, avg(duration) AS avg_s, approx_percentile_cont(duration, 0.95) AS p95_s FROM records WHERE duration IS NOT NULL GROUP BY span_name ORDER BY p95_s DESC.\n2. Narrow to a service with a WHERE service_name = '...' clause when one was named.\n3. Take the worst span_name and run Search Records for its slowest instances, then Get Trace on one to see where the time goes.\n\n## Output\nA table of the slowest operations by p95 with call counts, and for the worst one a breakdown of which child span consumes the time.", + }, + { + name: 'reconstruct-logfire-trace', + description: 'Walk a single Logfire trace end to end and explain what happened.', + content: + '# Reconstruct Logfire Trace\n\nTurn a trace ID into a readable story of one request.\n\n## Steps\n1. Run Get Trace with the trace ID. Spans come back earliest first.\n2. Build the tree using spanId and parentSpanId to see which operations nested inside which.\n3. Note each span duration to find where time was spent, and flag any span where isException is true.\n4. If the trace is truncated, raise the limit and refetch.\n\n## Output\nAn ordered walkthrough of the request: the entry point, each significant nested operation with its duration, and where it failed or ended. Call out the single slowest span and the first exception.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index d3bc8cea720..70f443bec70 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -178,6 +178,7 @@ import { LinearBlock, LinearBlockMeta, LinearV2Block } from '@/blocks/blocks/lin import { LinkedInBlock, LinkedInBlockMeta } from '@/blocks/blocks/linkedin' import { LinkupBlock, LinkupBlockMeta } from '@/blocks/blocks/linkup' import { LinqBlock, LinqBlockMeta } from '@/blocks/blocks/linq' +import { LogfireBlock, LogfireBlockMeta } from '@/blocks/blocks/logfire' import { LogsBlock, LogsV2Block } from '@/blocks/blocks/logs' import { LoopsBlock, LoopsBlockMeta } from '@/blocks/blocks/loops' import { LumaBlock, LumaBlockMeta } from '@/blocks/blocks/luma' @@ -505,6 +506,7 @@ export const BLOCK_REGISTRY: Record = { linkedin: LinkedInBlock, linkup: LinkupBlock, linq: LinqBlock, + logfire: LogfireBlock, logs: LogsBlock, logs_v2: LogsV2Block, loops: LoopsBlock, @@ -805,6 +807,7 @@ export const BLOCK_META_REGISTRY: Record = { linkedin: LinkedInBlockMeta, linkup: LinkupBlockMeta, linq: LinqBlockMeta, + logfire: LogfireBlockMeta, loops: LoopsBlockMeta, luma: LumaBlockMeta, mailchimp: MailchimpBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index a10c0acf1f9..efedba2000d 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -8758,3 +8758,21 @@ export function RocketlaneIcon(props: SVGProps) { ) } + +/** + * Pydantic Logfire. Single-fill mark drawn with `fill='currentColor'` so it + * takes white on its brand tile and the block's `iconColor` when rendered bare. + */ +export function LogfireIcon(props: SVGProps) { + return ( + + + + ) +} diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index a23744c0c25..ff7c8965ac8 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -132,6 +132,7 @@ import { LinkedInIcon, LinkupIcon, LinqIcon, + LogfireIcon, LoopsIcon, LumaIcon, MailchimpIcon, @@ -392,6 +393,7 @@ export const blockTypeToIconMap: Record = { linkedin: LinkedInIcon, linkup: LinkupIcon, linq: LinqIcon, + logfire: LogfireIcon, logs_v2: Library, loops: LoopsIcon, luma: LumaIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 8908eefeb49..a5c07a751e3 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -11614,6 +11614,41 @@ "integrationType": "communication", "tags": ["messaging", "automation", "webhooks"] }, + { + "type": "logfire", + "slug": "logfire", + "name": "Logfire", + "description": "Query traces, logs, and metrics in Pydantic Logfire", + "longDescription": "Integrate Pydantic Logfire into workflows. Run SQL over your observability data, search spans and logs with structured filters, pull an entire trace by ID, and confirm which project a read token targets.", + "bgColor": "#E520E9", + "iconName": "LogfireIcon", + "docsUrl": "https://docs.sim.ai/integrations/logfire", + "operations": [ + { + "name": "Search Records", + "description": "Search Logfire spans and logs using structured filters for message text, service, span name, severity, environment, and exceptions. Returns the most recent matches first without writing SQL." + }, + { + "name": "Run SQL Query", + "description": "Run a read-only SQL query against Logfire traces, logs, and metrics. Reads the records and metrics tables using PostgreSQL-compatible syntax." + }, + { + "name": "Get Trace", + "description": "Fetch every span and log belonging to a Logfire trace, ordered from earliest to latest, so a single request can be reconstructed end to end." + }, + { + "name": "Get Token Info", + "description": "Resolve which Logfire organization and project a read token belongs to. Useful for confirming a credential targets the expected project before querying it." + } + ], + "operationCount": 4, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "observability", + "tags": ["monitoring", "error-tracking", "incident-management"] + }, { "type": "loops", "slug": "loops", diff --git a/apps/sim/tools/logfire/get_token_info.test.ts b/apps/sim/tools/logfire/get_token_info.test.ts new file mode 100644 index 00000000000..37feb61eaed --- /dev/null +++ b/apps/sim/tools/logfire/get_token_info.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { logfireGetTokenInfoTool } from '@/tools/logfire/get_token_info' + +const baseParams = { apiKey: 'test-read-token' } + +describe('logfireGetTokenInfoTool request', () => { + it('targets the read-token-info endpoint with a GET', () => { + expect(logfireGetTokenInfoTool.request.url(baseParams)).toBe( + 'https://logfire-us.pydantic.dev/v1/read-token-info' + ) + expect(logfireGetTokenInfoTool.request.method).toBe('GET') + }) + + it('honours a self-hosted host', () => { + expect( + logfireGetTokenInfoTool.request.url({ ...baseParams, host: 'https://logfire.example.com' }) + ).toBe('https://logfire.example.com/v1/read-token-info') + }) +}) + +describe('logfireGetTokenInfoTool transformResponse', () => { + it('maps the snake_case token info into the block outputs', async () => { + const response = new Response( + JSON.stringify({ organization_name: 'acme', project_name: 'backend' }), + { status: 200 } + ) + + const result = await logfireGetTokenInfoTool.transformResponse?.(response, baseParams) + + expect(result?.success).toBe(true) + expect(result?.output).toEqual({ organizationName: 'acme', projectName: 'backend' }) + }) + + it('nulls fields the API omitted', async () => { + const response = new Response(JSON.stringify({}), { status: 200 }) + const result = await logfireGetTokenInfoTool.transformResponse?.(response, baseParams) + + expect(result?.output).toEqual({ organizationName: null, projectName: null }) + }) +}) diff --git a/apps/sim/tools/logfire/get_token_info.ts b/apps/sim/tools/logfire/get_token_info.ts new file mode 100644 index 00000000000..3b3bfc22625 --- /dev/null +++ b/apps/sim/tools/logfire/get_token_info.ts @@ -0,0 +1,76 @@ +import type { + LogfireGetTokenInfoParams, + LogfireGetTokenInfoResponse, + LogfireReadTokenInfo, +} from '@/tools/logfire/types' +import { + LOGFIRE_READ_TOKEN_INFO_PATH, + logfireHeaders, + logfireUrl, + parseLogfireResponse, +} from '@/tools/logfire/utils' +import type { ToolConfig } from '@/tools/types' + +export const logfireGetTokenInfoTool: ToolConfig< + LogfireGetTokenInfoParams, + LogfireGetTokenInfoResponse +> = { + id: 'logfire_get_token_info', + name: 'Logfire Get Token Info', + description: + 'Resolve which Logfire organization and project a read token belongs to. Useful for confirming a credential targets the expected project before querying it.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Logfire read token', + }, + region: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Logfire data region: auto, us, or eu. Auto reads the region from the token.', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'Base URL of a self-hosted Logfire instance, e.g. https://logfire.example.com. Overrides region. Leave blank for Logfire Cloud.', + }, + }, + + request: { + url: (params) => logfireUrl(params, LOGFIRE_READ_TOKEN_INFO_PATH), + method: 'GET', + headers: (params) => logfireHeaders(params.apiKey), + }, + + transformResponse: async (response) => { + const info = await parseLogfireResponse(response) + + return { + success: true, + output: { + organizationName: info.organization_name ?? null, + projectName: info.project_name ?? null, + }, + } + }, + + outputs: { + organizationName: { + type: 'string', + description: 'Logfire organization the read token belongs to', + nullable: true, + }, + projectName: { + type: 'string', + description: 'Logfire project the read token belongs to', + nullable: true, + }, + }, +} diff --git a/apps/sim/tools/logfire/get_trace.test.ts b/apps/sim/tools/logfire/get_trace.test.ts new file mode 100644 index 00000000000..28d25c558ea --- /dev/null +++ b/apps/sim/tools/logfire/get_trace.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { logfireGetTraceTool } from '@/tools/logfire/get_trace' + +/** + * Build a synthetic read token for a region. Assembled from a template rather + * than written out as a literal so no credential-shaped string sits in the + * source; only the `pylf_v{n}_{region}_` prefix is meaningful to the code here. + */ +const readToken = (region: string) => `pylf_v1_${region}_fixture` + +const baseParams = { + apiKey: readToken('eu'), + traceId: '0123456789abcdef0123456789abcdef', +} + +describe('logfireGetTraceTool request', () => { + it('routes EU tokens to the EU host', () => { + expect(logfireGetTraceTool.request.url(baseParams)).toBe( + 'https://logfire-eu.pydantic.dev/v2/query' + ) + }) + + it('filters on the trace id and orders spans chronologically', () => { + const body = logfireGetTraceTool.request.body?.({ + ...baseParams, + traceId: ' 0123456789abcdef0123456789abcdef ', + }) as Record + + expect(String(body.sql)).toContain("trace_id = '0123456789abcdef0123456789abcdef'") + expect(String(body.sql)).toContain('ORDER BY start_timestamp ASC') + }) + + it('escapes a trace id so it cannot break out of the literal', () => { + const body = logfireGetTraceTool.request.body?.({ + ...baseParams, + traceId: "abc' OR 1=1 --", + }) as Record + + expect(String(body.sql)).toContain("trace_id = 'abc'' OR 1=1 --'") + expect(String(body.sql)).not.toContain("'abc' OR 1=1") + }) +}) + +describe('logfireGetTraceTool transformResponse', () => { + it('normalizes every span in the trace from the wire payload', async () => { + const response = new Response( + JSON.stringify({ + schema: { + fields: [{ name: 'span_id', data_type: 'Utf8', nullable: false }], + metadata: {}, + }, + data: [ + { span_id: 'root', parent_span_id: null, span_name: 'GET /orders', duration: 1.5 }, + { span_id: 'child', parent_span_id: 'root', span_name: 'db.query', duration: 0.4 }, + ], + }), + { status: 200 } + ) + + const result = await logfireGetTraceTool.transformResponse?.(response, baseParams) + + expect(result?.success).toBe(true) + expect(result?.output.rowCount).toBe(2) + expect(result?.output.rows[0]).toMatchObject({ spanId: 'root', parentSpanId: null }) + expect(result?.output.rows[1]).toMatchObject({ spanId: 'child', parentSpanId: 'root' }) + expect(result?.output.sql).toContain('trace_id =') + }) + + it('returns an empty result set for an unknown trace id', async () => { + const response = new Response(JSON.stringify({ schema: { fields: [] }, data: [] }), { + status: 200, + }) + const result = await logfireGetTraceTool.transformResponse?.(response, baseParams) + + expect(result?.output.rows).toEqual([]) + expect(result?.output.rowCount).toBe(0) + }) +}) diff --git a/apps/sim/tools/logfire/get_trace.ts b/apps/sim/tools/logfire/get_trace.ts new file mode 100644 index 00000000000..dc078c376d3 --- /dev/null +++ b/apps/sim/tools/logfire/get_trace.ts @@ -0,0 +1,113 @@ +import type { + LogfireGetTraceParams, + LogfireQueryApiResponse, + LogfireRecordsResponse, +} from '@/tools/logfire/types' +import { LOGFIRE_RECORD_OUTPUT_ITEM } from '@/tools/logfire/types' +import { + buildLogfireQueryBody, + extractLogfireRows, + LOGFIRE_QUERY_PATH, + LOGFIRE_RECORD_PROJECTION, + logfireHeaders, + logfireUrl, + normalizeLogfireRecord, + parseLogfireResponse, + sqlLiteral, +} from '@/tools/logfire/utils' +import type { ToolConfig } from '@/tools/types' + +const buildTraceSql = (traceId: string): string => + `SELECT ${LOGFIRE_RECORD_PROJECTION} FROM records WHERE trace_id = ${sqlLiteral(traceId.trim())} ORDER BY start_timestamp ASC` + +export const logfireGetTraceTool: ToolConfig = { + id: 'logfire_get_trace', + name: 'Logfire Get Trace', + description: + 'Fetch every span and log belonging to a Logfire trace, ordered from earliest to latest, so a single request can be reconstructed end to end.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Logfire read token', + }, + region: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Logfire data region: auto, us, or eu. Auto reads the region from the token.', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'Base URL of a self-hosted Logfire instance, e.g. https://logfire.example.com. Overrides region. Leave blank for Logfire Cloud.', + }, + traceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: '32-character hexadecimal trace identifier', + }, + minTimestamp: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ISO 8601 lower bound on start_timestamp. Defaults to 2020-01-01T00:00:00Z when omitted.', + }, + maxTimestamp: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO 8601 upper bound on start_timestamp', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum spans to return. Logfire defaults to 100 and caps at 10000.', + }, + }, + + request: { + url: (params) => logfireUrl(params, LOGFIRE_QUERY_PATH), + method: 'POST', + headers: (params) => logfireHeaders(params.apiKey), + body: (params) => + buildLogfireQueryBody({ + sql: buildTraceSql(params.traceId), + minTimestamp: params.minTimestamp, + maxTimestamp: params.maxTimestamp, + limit: params.limit, + }), + }, + + transformResponse: async (response, params) => { + const results = await parseLogfireResponse(response) + const rows = extractLogfireRows(results).map(normalizeLogfireRecord) + + return { + success: true, + output: { + rows, + rowCount: rows.length, + sql: params ? buildTraceSql(params.traceId) : '', + }, + } + }, + + outputs: { + rows: { + type: 'array', + description: 'Spans and logs in the trace, earliest first', + items: LOGFIRE_RECORD_OUTPUT_ITEM, + }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + sql: { type: 'string', description: 'SQL query that was executed against Logfire' }, + }, +} diff --git a/apps/sim/tools/logfire/index.ts b/apps/sim/tools/logfire/index.ts new file mode 100644 index 00000000000..ac7852a4d78 --- /dev/null +++ b/apps/sim/tools/logfire/index.ts @@ -0,0 +1,5 @@ +export { logfireGetTokenInfoTool } from '@/tools/logfire/get_token_info' +export { logfireGetTraceTool } from '@/tools/logfire/get_trace' +export { logfireQueryTool } from '@/tools/logfire/query' +export { logfireSearchRecordsTool } from '@/tools/logfire/search_records' +export type * from '@/tools/logfire/types' diff --git a/apps/sim/tools/logfire/query.test.ts b/apps/sim/tools/logfire/query.test.ts new file mode 100644 index 00000000000..c3ce7526f80 --- /dev/null +++ b/apps/sim/tools/logfire/query.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { logfireQueryTool } from '@/tools/logfire/query' +import type { LogfireQueryParams } from '@/tools/logfire/types' + +const baseParams: LogfireQueryParams = { + apiKey: 'test-read-token', + sql: 'SELECT message FROM records', +} + +describe('logfireQueryTool request', () => { + it('targets the regional query endpoint', () => { + expect(logfireQueryTool.request.url(baseParams)).toBe( + 'https://logfire-us.pydantic.dev/v2/query' + ) + }) + + it('sends a self-hosted instance to the configured host', () => { + expect(logfireQueryTool.request.url({ ...baseParams, host: 'logfire.example.com' })).toBe( + 'https://logfire.example.com/v2/query' + ) + }) + + it('passes the caller SQL through untouched alongside the window', () => { + const body = logfireQueryTool.request.body?.({ + ...baseParams, + minTimestamp: '2026-07-29T00:00:00Z', + limit: 25, + timezone: 'Europe/Paris', + environment: 'production', + }) as Record + + expect(body).toMatchObject({ + sql: 'SELECT message FROM records', + min_timestamp: '2026-07-29T00:00:00Z', + limit: 25, + timezone: 'Europe/Paris', + deployment_environment: ['production'], + include_schema: true, + }) + }) +}) + +describe('logfireQueryTool transformResponse', () => { + it('returns raw rows and the column metadata from the wire payload', async () => { + const response = new Response( + JSON.stringify({ + schema: { + fields: [ + { name: 'service_name', data_type: 'Utf8', nullable: false }, + { name: 'calls', data_type: 'Int64', nullable: true }, + ], + metadata: {}, + }, + data: [ + { service_name: 'checkout-api', calls: 12 }, + { service_name: 'billing', calls: 3 }, + ], + }), + { status: 200 } + ) + + const result = await logfireQueryTool.transformResponse?.(response, baseParams) + + expect(result?.success).toBe(true) + expect(result?.output.rowCount).toBe(2) + expect(result?.output.rows).toEqual([ + { service_name: 'checkout-api', calls: 12 }, + { service_name: 'billing', calls: 3 }, + ]) + expect(result?.output.columns).toEqual([ + { name: 'service_name', datatype: 'Utf8', nullable: false }, + { name: 'calls', datatype: 'Int64', nullable: true }, + ]) + }) + + it('returns an empty result set when the query matched nothing', async () => { + const response = new Response(JSON.stringify({ schema: { fields: [] }, data: [] }), { + status: 200, + }) + const result = await logfireQueryTool.transformResponse?.(response, baseParams) + + expect(result?.output.rows).toEqual([]) + expect(result?.output.columns).toEqual([]) + expect(result?.output.rowCount).toBe(0) + }) +}) diff --git a/apps/sim/tools/logfire/query.ts b/apps/sim/tools/logfire/query.ts new file mode 100644 index 00000000000..43c1ebca012 --- /dev/null +++ b/apps/sim/tools/logfire/query.ts @@ -0,0 +1,137 @@ +import type { + LogfireQueryApiResponse, + LogfireQueryParams, + LogfireQueryResponse, +} from '@/tools/logfire/types' +import { + buildLogfireQueryBody, + extractLogfireColumns, + extractLogfireRows, + LOGFIRE_QUERY_PATH, + logfireHeaders, + logfireUrl, + parseLogfireResponse, +} from '@/tools/logfire/utils' +import type { ToolConfig } from '@/tools/types' + +export const logfireQueryTool: ToolConfig = { + id: 'logfire_query', + name: 'Logfire Query', + description: + 'Run a read-only SQL query against Logfire traces, logs, and metrics. Reads the records and metrics tables using PostgreSQL-compatible syntax.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Logfire read token', + }, + region: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Logfire data region: auto, us, or eu. Auto reads the region from the token.', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'Base URL of a self-hosted Logfire instance, e.g. https://logfire.example.com. Overrides region. Leave blank for Logfire Cloud.', + }, + sql: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'SQL SELECT query to run against the records or metrics table', + }, + minTimestamp: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ISO 8601 lower bound on start_timestamp. Defaults to 2020-01-01T00:00:00Z when omitted.', + }, + maxTimestamp: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO 8601 upper bound on start_timestamp', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum rows to return. Logfire defaults to 100 and caps at 10000.', + }, + timezone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'IANA timezone used to evaluate the query, for example Europe/Paris', + }, + environment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Restrict results to a single deployment environment', + }, + }, + + request: { + url: (params) => logfireUrl(params, LOGFIRE_QUERY_PATH), + method: 'POST', + headers: (params) => logfireHeaders(params.apiKey), + body: (params) => + buildLogfireQueryBody({ + sql: params.sql, + minTimestamp: params.minTimestamp, + maxTimestamp: params.maxTimestamp, + limit: params.limit, + timezone: params.timezone, + environment: params.environment, + }), + }, + + transformResponse: async (response) => { + const results = await parseLogfireResponse(response) + const rows = extractLogfireRows(results) + + return { + success: true, + output: { + rows, + columns: extractLogfireColumns(results), + rowCount: rows.length, + }, + } + }, + + outputs: { + rows: { + type: 'array', + description: 'Result rows. Row fields depend on the query projection.', + items: { type: 'object', description: 'A single result row' }, + }, + columns: { + type: 'array', + description: 'Column metadata for the result set', + items: { + type: 'object', + description: 'Column metadata', + properties: { + name: { type: 'string', description: 'Column name', nullable: true }, + datatype: { type: 'json', description: 'Arrow datatype of the column' }, + nullable: { + type: 'boolean', + description: 'Whether the column is nullable', + nullable: true, + }, + }, + }, + }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/logfire/search_records.test.ts b/apps/sim/tools/logfire/search_records.test.ts new file mode 100644 index 00000000000..f25e1b783d6 --- /dev/null +++ b/apps/sim/tools/logfire/search_records.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { logfireSearchRecordsTool } from '@/tools/logfire/search_records' +import type { LogfireSearchRecordsParams } from '@/tools/logfire/types' + +const baseParams: LogfireSearchRecordsParams = { apiKey: 'test-read-token' } + +/** Shape `/v2/query` actually returns: rows under `data`, columns under `schema.fields`. */ +const WIRE_RESPONSE = { + schema: { + fields: [ + { name: 'message', data_type: 'Utf8', nullable: false }, + { name: 'level', data_type: 'Utf8', nullable: true }, + ], + metadata: {}, + }, + data: [ + { message: 'about to raise an error', level: 'error', trace_id: 'abc', is_exception: true }, + { message: 'ok', level: 'info' }, + ], +} + +const sqlFor = (params: LogfireSearchRecordsParams): string => { + const body = logfireSearchRecordsTool.request.body?.(params) as Record + return String(body.sql) +} + +describe('logfireSearchRecordsTool request', () => { + it('targets the regional query endpoint', () => { + expect(logfireSearchRecordsTool.request.url(baseParams)).toBe( + 'https://logfire-us.pydantic.dev/v2/query' + ) + }) + + it('omits WHERE entirely when no filters are supplied', () => { + const sql = sqlFor(baseParams) + expect(sql).not.toContain('WHERE') + expect(sql).toContain('FROM records') + expect(sql).toContain('ORDER BY start_timestamp DESC') + expect(sql).toContain('level_name(level) AS level') + }) + + it('ANDs every supplied filter together', () => { + const sql = sqlFor({ + ...baseParams, + query: 'timeout', + service: 'checkout-api', + spanName: 'GET /orders', + minLevel: 'error', + exceptionsOnly: true, + }) + + expect(sql).toContain("contains(lower(message), 'timeout')") + expect(sql).toContain("service_name = 'checkout-api'") + expect(sql).toContain("span_name = 'GET /orders'") + expect(sql).toContain("level >= 'error'") + expect(sql).toContain('is_exception') + expect(sql.match(/ AND /g)).toHaveLength(4) + }) + + it('ignores blank filter values', () => { + expect(sqlFor({ ...baseParams, query: ' ', service: '' })).not.toContain('WHERE') + }) + + it('neutralizes SQL injection attempts in filter values', () => { + const sql = sqlFor({ ...baseParams, service: "x' OR 1=1 --" }) + expect(sql).toContain("service_name = 'x'' OR 1=1 --'") + expect(sql).not.toContain("'x' OR 1=1") + }) + + it('sends the time window alongside the generated SQL', () => { + const body = logfireSearchRecordsTool.request.body?.({ + ...baseParams, + minTimestamp: '2026-07-29T00:00:00Z', + limit: 25, + environment: 'production', + }) as Record + + expect(body).toMatchObject({ + min_timestamp: '2026-07-29T00:00:00Z', + limit: 25, + deployment_environment: ['production'], + include_schema: true, + }) + }) +}) + +describe('logfireSearchRecordsTool transformResponse', () => { + it('normalizes rows from the wire payload and reports the executed SQL', async () => { + const response = new Response(JSON.stringify(WIRE_RESPONSE), { status: 200 }) + + const params = { ...baseParams, service: 'checkout-api' } + const result = await logfireSearchRecordsTool.transformResponse?.(response, params) + + expect(result?.success).toBe(true) + expect(result?.output.rowCount).toBe(2) + expect(result?.output.rows[0]).toMatchObject({ + message: 'about to raise an error', + level: 'error', + traceId: 'abc', + isException: true, + }) + expect(result?.output.sql).toContain("service_name = 'checkout-api'") + }) + + it('returns an empty result set when the query matched nothing', async () => { + const response = new Response(JSON.stringify({ schema: { fields: [] }, data: [] }), { + status: 200, + }) + const result = await logfireSearchRecordsTool.transformResponse?.(response, baseParams) + + expect(result?.output.rows).toEqual([]) + expect(result?.output.rowCount).toBe(0) + }) +}) diff --git a/apps/sim/tools/logfire/search_records.ts b/apps/sim/tools/logfire/search_records.ts new file mode 100644 index 00000000000..709225a0cb7 --- /dev/null +++ b/apps/sim/tools/logfire/search_records.ts @@ -0,0 +1,172 @@ +import type { + LogfireQueryApiResponse, + LogfireRecordsResponse, + LogfireSearchRecordsParams, +} from '@/tools/logfire/types' +import { LOGFIRE_RECORD_OUTPUT_ITEM } from '@/tools/logfire/types' +import { + buildLogfireQueryBody, + cleanOptionalString, + extractLogfireRows, + LOGFIRE_QUERY_PATH, + LOGFIRE_RECORD_PROJECTION, + logfireHeaders, + logfireUrl, + normalizeLogfireRecord, + parseLogfireResponse, + sqlContains, + sqlLiteral, +} from '@/tools/logfire/utils' +import type { ToolConfig } from '@/tools/types' + +/** + * Build a `records` query from structured filters. Every user-supplied value is + * emitted as an escaped SQL literal, never interpolated raw. + */ +function buildSearchSql(params: LogfireSearchRecordsParams): string { + const conditions: string[] = [] + + const query = cleanOptionalString(params.query) + if (query) conditions.push(sqlContains('message', query)) + + const service = cleanOptionalString(params.service) + if (service) conditions.push(`service_name = ${sqlLiteral(service)}`) + + const spanName = cleanOptionalString(params.spanName) + if (spanName) conditions.push(`span_name = ${sqlLiteral(spanName)}`) + + const minLevel = cleanOptionalString(params.minLevel) + if (minLevel) conditions.push(`level >= ${sqlLiteral(minLevel)}`) + + if (params.exceptionsOnly) conditions.push('is_exception') + + const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '' + + return `SELECT ${LOGFIRE_RECORD_PROJECTION} FROM records${where} ORDER BY start_timestamp DESC` +} + +export const logfireSearchRecordsTool: ToolConfig< + LogfireSearchRecordsParams, + LogfireRecordsResponse +> = { + id: 'logfire_search_records', + name: 'Logfire Search Records', + description: + 'Search Logfire spans and logs using structured filters for message text, service, span name, severity, environment, and exceptions. Returns the most recent matches first without writing SQL.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Logfire read token', + }, + region: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Logfire data region: auto, us, or eu. Auto reads the region from the token.', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'Base URL of a self-hosted Logfire instance, e.g. https://logfire.example.com. Overrides region. Leave blank for Logfire Cloud.', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Case-insensitive text to match within the record message', + }, + service: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Exact service name to filter on', + }, + spanName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Exact span name to filter on', + }, + minLevel: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Minimum severity to include: trace, debug, info, notice, warn, error, or fatal', + }, + exceptionsOnly: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only return records that recorded an exception', + }, + environment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Restrict results to a single deployment environment', + }, + minTimestamp: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ISO 8601 lower bound on start_timestamp. Defaults to 2020-01-01T00:00:00Z when omitted.', + }, + maxTimestamp: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO 8601 upper bound on start_timestamp', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum records to return. Logfire defaults to 100 and caps at 10000.', + }, + }, + + request: { + url: (params) => logfireUrl(params, LOGFIRE_QUERY_PATH), + method: 'POST', + headers: (params) => logfireHeaders(params.apiKey), + body: (params) => + buildLogfireQueryBody({ + sql: buildSearchSql(params), + minTimestamp: params.minTimestamp, + maxTimestamp: params.maxTimestamp, + limit: params.limit, + environment: params.environment, + }), + }, + + transformResponse: async (response, params) => { + const results = await parseLogfireResponse(response) + const rows = extractLogfireRows(results).map(normalizeLogfireRecord) + + return { + success: true, + output: { + rows, + rowCount: rows.length, + sql: params ? buildSearchSql(params) : '', + }, + } + }, + + outputs: { + rows: { + type: 'array', + description: 'Matching spans and logs, most recent first', + items: LOGFIRE_RECORD_OUTPUT_ITEM, + }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + sql: { type: 'string', description: 'SQL query that was executed against Logfire' }, + }, +} diff --git a/apps/sim/tools/logfire/types.ts b/apps/sim/tools/logfire/types.ts new file mode 100644 index 00000000000..1e52cac18be --- /dev/null +++ b/apps/sim/tools/logfire/types.ts @@ -0,0 +1,191 @@ +import type { ToolOutputProperty, ToolResponse } from '@/tools/types' + +/** + * Logfire data region. `auto` resolves the region from the read token's + * `pylf_v{n}_{region}_` prefix, matching the official Logfire SDK. + */ +export type LogfireRegion = 'auto' | 'us' | 'eu' + +/** + * Severity names Logfire accepts in `level` comparisons. Logfire stores the + * level as an OpenTelemetry severity number but rewrites string comparisons, + * so `level >= 'error'` works directly in SQL. + */ +export type LogfireLevel = 'trace' | 'debug' | 'info' | 'notice' | 'warn' | 'error' | 'fatal' + +export interface LogfireBaseParams { + apiKey: string + region?: LogfireRegion + /** + * Base URL of a self-hosted Logfire instance, e.g. `https://logfire.example.com`. + * Takes precedence over `region`. Leave unset for Logfire Cloud. + */ + host?: string +} + +/** Time-window and paging controls accepted by every `/v2/query` request. */ +export interface LogfireQueryWindowParams { + minTimestamp?: string + maxTimestamp?: string + limit?: number +} + +/** Column metadata returned alongside the rows of a query result. */ +export interface LogfireColumn { + name: string | null + datatype: unknown + nullable: boolean | null +} + +/** + * Column metadata exactly as `/v2/query` sends it. The wire key is `data_type`; + * the official SDK renames it to `datatype` before handing results to callers, + * and {@link LogfireColumn} keeps that friendlier name. + */ +export interface LogfireApiField { + name?: string | null + data_type?: unknown + nullable?: boolean | null +} + +/** + * Raw `/v2/query` response for `Accept: application/json`. Rows arrive under + * `data` and column metadata under `schema.fields`. Every request sets + * `include_schema: true`, matching the official SDK. + */ +export interface LogfireQueryApiResponse { + schema?: { fields?: LogfireApiField[] | null } | null + data?: Record[] | null +} + +/** Shape of `GET /v1/read-token-info`. */ +export interface LogfireReadTokenInfo { + organization_name?: string | null + project_name?: string | null +} + +/** + * A row from the fixed `records` projection shared by the search and trace + * tools. Field names match the `records` table columns Logfire documents. + */ +export interface LogfireRecord { + startTimestamp: string | null + endTimestamp: string | null + duration: number | null + level: string | null + message: string | null + spanName: string | null + kind: string | null + serviceName: string | null + deploymentEnvironment: string | null + traceId: string | null + spanId: string | null + parentSpanId: string | null + isException: boolean | null + exceptionType: string | null + exceptionMessage: string | null +} + +/** + * Output schema for one {@link LogfireRecord}. Kept beside the interface it + * describes so the two stay in step, and in this file because the docs + * generator only resolves `items:` const references from a tool's `types.ts`. + */ +export const LOGFIRE_RECORD_OUTPUT_ITEM: NonNullable = { + type: 'object', + description: 'A span or log from the records table', + properties: { + startTimestamp: { type: 'string', description: 'UTC time the span started', nullable: true }, + endTimestamp: { type: 'string', description: 'UTC time the span ended', nullable: true }, + duration: { + type: 'number', + description: 'Span duration in seconds. Null for logs.', + nullable: true, + }, + level: { + type: 'string', + description: 'Severity name, such as info, warn, or error', + nullable: true, + }, + message: { type: 'string', description: 'Human-readable message', nullable: true }, + spanName: { type: 'string', description: 'Template label for similar records', nullable: true }, + kind: { + type: 'string', + description: 'Record kind: span, log, or span_event', + nullable: true, + }, + serviceName: { type: 'string', description: 'Service that emitted the record', nullable: true }, + deploymentEnvironment: { + type: 'string', + description: 'Deployment environment of the record', + nullable: true, + }, + traceId: { type: 'string', description: 'Trace this record belongs to', nullable: true }, + spanId: { type: 'string', description: 'Identifier of this span', nullable: true }, + parentSpanId: { type: 'string', description: 'Parent span identifier', nullable: true }, + isException: { + type: 'boolean', + description: 'Whether an exception was recorded on the span', + nullable: true, + }, + exceptionType: { + type: 'string', + description: 'Fully qualified exception class name', + nullable: true, + }, + exceptionMessage: { type: 'string', description: 'Exception message', nullable: true }, + }, +} + +export interface LogfireQueryParams extends LogfireBaseParams, LogfireQueryWindowParams { + sql: string + timezone?: string + environment?: string +} + +export interface LogfireQueryResponse extends ToolResponse { + output: { + rows: Record[] + columns: LogfireColumn[] + rowCount: number + } +} + +export interface LogfireSearchRecordsParams extends LogfireBaseParams, LogfireQueryWindowParams { + query?: string + service?: string + spanName?: string + minLevel?: LogfireLevel + environment?: string + exceptionsOnly?: boolean +} + +export interface LogfireGetTraceParams extends LogfireBaseParams, LogfireQueryWindowParams { + traceId: string +} + +/** + * Shared result of the two tools that project {@link LogfireRecord} rows and + * echo the SQL they generated. + */ +export interface LogfireRecordsResponse extends ToolResponse { + output: { + rows: LogfireRecord[] + rowCount: number + sql: string + } +} + +export type LogfireGetTokenInfoParams = LogfireBaseParams + +export interface LogfireGetTokenInfoResponse extends ToolResponse { + output: { + organizationName: string | null + projectName: string | null + } +} + +export type LogfireResponse = + | LogfireQueryResponse + | LogfireRecordsResponse + | LogfireGetTokenInfoResponse diff --git a/apps/sim/tools/logfire/utils.test.ts b/apps/sim/tools/logfire/utils.test.ts new file mode 100644 index 00000000000..d9654409297 --- /dev/null +++ b/apps/sim/tools/logfire/utils.test.ts @@ -0,0 +1,304 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { LogfireRegion } from '@/tools/logfire/types' +import { + buildLogfireQueryBody, + extractLogfireColumns, + extractLogfireRows, + getLogfireBaseUrl, + logfireHeaders, + normalizeLogfireRecord, + parseLogfireResponse, + sqlContains, + sqlLiteral, +} from '@/tools/logfire/utils' + +const US_BASE = 'https://logfire-us.pydantic.dev' +const EU_BASE = 'https://logfire-eu.pydantic.dev' + +/** + * Build a synthetic read token for a region. Assembled from a template rather + * than written out as a literal so no credential-shaped string sits in the + * source; only the `pylf_v{n}_{region}_` prefix is meaningful to the code here. + */ +const readToken = (region: string, version = 1) => `pylf_v${version}_${region}_fixture` + +const US_TOKEN = readToken('us') +const EU_TOKEN = readToken('eu') +const EU_TOKEN_V2 = readToken('eu', 2) +const UNKNOWN_REGION_TOKEN = readToken('apac') + +describe('getLogfireBaseUrl', () => { + it('prefers an explicit region over the token prefix', () => { + expect(getLogfireBaseUrl(EU_TOKEN, 'us')).toBe(US_BASE) + expect(getLogfireBaseUrl(US_TOKEN, 'eu')).toBe(EU_BASE) + }) + + it('reads the region from the token prefix when set to auto', () => { + expect(getLogfireBaseUrl(EU_TOKEN, 'auto')).toBe(EU_BASE) + expect(getLogfireBaseUrl(US_TOKEN, 'auto')).toBe(US_BASE) + expect(getLogfireBaseUrl(EU_TOKEN_V2)).toBe(EU_BASE) + }) + + it('treats a token with no region prefix as US', () => { + expect(getLogfireBaseUrl('legacy-token-without-region')).toBe(US_BASE) + expect(getLogfireBaseUrl('')).toBe(US_BASE) + }) + + it('refuses to guess a host for a region it does not know', () => { + expect(() => getLogfireBaseUrl(UNKNOWN_REGION_TOKEN)).toThrow( + "Unrecognized Logfire token region 'apac'" + ) + }) + + it('rejects an explicit region outside the known set rather than building undefined/', () => { + expect(() => getLogfireBaseUrl(US_TOKEN, 'apac' as unknown as LogfireRegion)).toThrow( + "Unrecognized Logfire region 'apac'" + ) + }) + + it('lets a self-hosted host override both the region and the token prefix', () => { + expect(getLogfireBaseUrl(EU_TOKEN, 'us', 'https://logfire.example.com')).toBe( + 'https://logfire.example.com' + ) + }) + + it('assumes HTTPS when the self-hosted host has no scheme', () => { + expect(getLogfireBaseUrl('token', 'auto', 'logfire.example.com')).toBe( + 'https://logfire.example.com' + ) + }) + + it('keeps an explicit port', () => { + expect(getLogfireBaseUrl('token', 'auto', 'logfire.example.com:8443')).toBe( + 'https://logfire.example.com:8443' + ) + }) + + it('strips trailing slashes so endpoint paths concatenate cleanly', () => { + expect(getLogfireBaseUrl('token', 'auto', 'https://logfire.example.com///')).toBe( + 'https://logfire.example.com' + ) + }) + + it('preserves a sub-path for instances served under a prefix', () => { + expect(getLogfireBaseUrl('token', 'auto', 'https://obs.example.com/logfire/')).toBe( + 'https://obs.example.com/logfire' + ) + }) + + it('ignores a blank host and falls back to region resolution', () => { + expect(getLogfireBaseUrl(EU_TOKEN, 'auto', ' ')).toBe(EU_BASE) + expect(getLogfireBaseUrl(EU_TOKEN, 'auto', undefined)).toBe(EU_BASE) + }) + + it('rejects a host carrying a query string or fragment that would swallow the path', () => { + expect(() => + getLogfireBaseUrl('token', 'auto', 'https://logfire.example.com/?last=1h') + ).toThrow('must not include a query string or fragment') + expect(() => getLogfireBaseUrl('token', 'auto', 'https://logfire.example.com#panel')).toThrow( + 'must not include a query string or fragment' + ) + }) + + it('rejects userinfo, which would send the read token to another origin', () => { + expect(() => getLogfireBaseUrl('token', 'auto', 'logfire.example.com@evil.com')).toThrow( + 'must not include credentials' + ) + }) +}) + +describe('logfireHeaders', () => { + it('trims the token so a pasted value does not corrupt the bearer credential', () => { + expect(logfireHeaders(` ${US_TOKEN}\n`).Authorization).toBe(`Bearer ${US_TOKEN}`) + }) +}) + +describe('sqlLiteral', () => { + it('wraps a value in single quotes', () => { + expect(sqlLiteral('checkout-api')).toBe("'checkout-api'") + }) + + it('escapes embedded single quotes so injected SQL stays a literal', () => { + expect(sqlLiteral("o'brien")).toBe("'o''brien'") + expect(sqlLiteral("' OR 1=1 --")).toBe("''' OR 1=1 --'") + }) +}) + +describe('sqlContains', () => { + it('builds a case-insensitive literal substring match', () => { + expect(sqlContains('message', 'Timeout')).toBe("contains(lower(message), 'timeout')") + }) + + it('keeps LIKE wildcards literal and escapes quotes', () => { + expect(sqlContains('message', "100%'")).toBe("contains(lower(message), '100%''')") + }) +}) + +describe('buildLogfireQueryBody', () => { + it('always sends sql, include_schema, and a min_timestamp floor', () => { + expect(buildLogfireQueryBody({ sql: 'SELECT 1' })).toEqual({ + sql: 'SELECT 1', + include_schema: true, + min_timestamp: '2020-01-01T00:00:00Z', + }) + }) + + it('omits optional fields that were not supplied', () => { + const body = buildLogfireQueryBody({ sql: 'SELECT 1', maxTimestamp: ' ', timezone: '' }) + expect(body).not.toHaveProperty('max_timestamp') + expect(body).not.toHaveProperty('timezone') + expect(body).not.toHaveProperty('limit') + expect(body).not.toHaveProperty('deployment_environment') + }) + + it('passes through a window that already carries an offset', () => { + expect( + buildLogfireQueryBody({ + sql: 'SELECT 1', + minTimestamp: '2026-07-01T00:00:00Z', + maxTimestamp: '2026-07-02T12:30:00+05:30', + timezone: 'Europe/Paris', + }) + ).toMatchObject({ + min_timestamp: '2026-07-01T00:00:00Z', + max_timestamp: '2026-07-02T12:30:00+05:30', + timezone: 'Europe/Paris', + }) + }) + + it('assumes UTC for a naive timestamp, which Logfire would otherwise reject', () => { + expect( + buildLogfireQueryBody({ + sql: 'SELECT 1', + minTimestamp: '2026-07-01T00:00:00', + maxTimestamp: '2026-07-02T00:00:00.123456', + }) + ).toMatchObject({ + min_timestamp: '2026-07-01T00:00:00Z', + max_timestamp: '2026-07-02T00:00:00.123456Z', + }) + }) + + it('widens a bare date to midnight UTC', () => { + expect(buildLogfireQueryBody({ sql: 'SELECT 1', minTimestamp: '2026-07-01' })).toMatchObject({ + min_timestamp: '2026-07-01T00:00:00Z', + }) + }) + + it('clamps limit into the range Logfire accepts', () => { + expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: 50 }).limit).toBe(50) + expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: 99999 }).limit).toBe(10000) + expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: 0 }).limit).toBe(1) + expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: 12.7 }).limit).toBe(12) + }) + + it('sends deployment_environment as an array', () => { + expect(buildLogfireQueryBody({ sql: 'SELECT 1', environment: 'production' })).toMatchObject({ + deployment_environment: ['production'], + }) + }) +}) + +describe('parseLogfireResponse', () => { + it('returns the parsed body', async () => { + const response = new Response(JSON.stringify({ data: [{ a: 1 }] }), { status: 200 }) + await expect(parseLogfireResponse(response)).resolves.toEqual({ data: [{ a: 1 }] }) + }) + + it('reports a non-JSON body rather than throwing a parse error', async () => { + const response = new Response('gateway', { status: 200 }) + await expect(parseLogfireResponse(response)).rejects.toThrow( + 'Logfire returned a response that is not valid JSON' + ) + }) +}) + +describe('extractLogfireRows', () => { + it('reads rows from the wire `data` key', () => { + expect( + extractLogfireRows({ + schema: { fields: [{ name: 'kind', data_type: 'Utf8', nullable: false }] }, + data: [{ kind: 'log' }, { kind: 'span' }], + }) + ).toEqual([{ kind: 'log' }, { kind: 'span' }]) + }) + + it('returns an empty array when the query matched nothing', () => { + expect(extractLogfireRows({ schema: { fields: [] }, data: [] })).toEqual([]) + expect(extractLogfireRows({})).toEqual([]) + }) +}) + +describe('extractLogfireColumns', () => { + it('reads column metadata from schema.fields and renames data_type', () => { + expect( + extractLogfireColumns({ + schema: { + fields: [ + { name: 'kind', data_type: 'Utf8', nullable: false }, + { name: 'tags', data_type: { List: { name: 'item' } }, nullable: true }, + ], + }, + data: [], + }) + ).toEqual([ + { name: 'kind', datatype: 'Utf8', nullable: false }, + { name: 'tags', datatype: { List: { name: 'item' } }, nullable: true }, + ]) + }) + + it('returns an empty array when no schema was returned', () => { + expect(extractLogfireColumns({ data: [] })).toEqual([]) + }) +}) + +describe('normalizeLogfireRecord', () => { + it('maps the records projection into camelCase fields', () => { + expect( + normalizeLogfireRecord({ + start_timestamp: '2026-07-29T10:00:00Z', + end_timestamp: '2026-07-29T10:00:01Z', + duration: 1.25, + level: 'error', + message: 'boom', + span_name: 'GET /orders', + kind: 'span', + service_name: 'checkout-api', + deployment_environment: 'production', + trace_id: 'abc', + span_id: 'def', + parent_span_id: 'ghi', + is_exception: true, + exception_type: 'ValueError', + exception_message: 'bad input', + }) + ).toEqual({ + startTimestamp: '2026-07-29T10:00:00Z', + endTimestamp: '2026-07-29T10:00:01Z', + duration: 1.25, + level: 'error', + message: 'boom', + spanName: 'GET /orders', + kind: 'span', + serviceName: 'checkout-api', + deploymentEnvironment: 'production', + traceId: 'abc', + spanId: 'def', + parentSpanId: 'ghi', + isException: true, + exceptionType: 'ValueError', + exceptionMessage: 'bad input', + }) + }) + + it('nulls fields that are absent or of an unexpected type', () => { + const record = normalizeLogfireRecord({ duration: 'slow', message: null }) + expect(record.duration).toBeNull() + expect(record.message).toBeNull() + expect(record.endTimestamp).toBeNull() + expect(record.isException).toBeNull() + }) +}) diff --git a/apps/sim/tools/logfire/utils.ts b/apps/sim/tools/logfire/utils.ts new file mode 100644 index 00000000000..e8fbe707e00 --- /dev/null +++ b/apps/sim/tools/logfire/utils.ts @@ -0,0 +1,253 @@ +import type { + LogfireBaseParams, + LogfireColumn, + LogfireQueryApiResponse, + LogfireRecord, + LogfireRegion, +} from '@/tools/logfire/types' + +const REGION_BASE_URLS = { + us: 'https://logfire-us.pydantic.dev', + eu: 'https://logfire-eu.pydantic.dev', +} as const + +type LogfireCloudRegion = keyof typeof REGION_BASE_URLS + +const isCloudRegion = (value: string): value is LogfireCloudRegion => + Object.hasOwn(REGION_BASE_URLS, value) + +/** Logfire read tokens carry their region as a `pylf_v{n}_{region}_` prefix. */ +const TOKEN_REGION_PATTERN = /^pylf_v\d+_([a-z]+)_/ + +/** SQL query endpoint, relative to the regional base URL. */ +export const LOGFIRE_QUERY_PATH = '/v2/query' + +/** Read token introspection endpoint, relative to the regional base URL. */ +export const LOGFIRE_READ_TOKEN_INFO_PATH = '/v1/read-token-info' + +/** + * Logfire requires `min_timestamp` on every query. When the caller leaves the + * window open, fall back to the same floor the official Logfire SDK uses. + */ +const LOGFIRE_MIN_TIMESTAMP_FALLBACK = '2020-01-01T00:00:00Z' + +/** Row limits Logfire enforces on `/v2/query`. */ +const LOGFIRE_MAX_LIMIT = 10000 +const LOGFIRE_MIN_LIMIT = 1 + +/** + * Fixed `records` projection shared by the search and trace tools. Every column + * is documented in the Logfire SQL reference; `level` is read through + * `level_name` so rows carry the readable severity rather than its number. + */ +export const LOGFIRE_RECORD_PROJECTION = [ + 'start_timestamp', + 'end_timestamp', + 'duration', + 'level_name(level) AS level', + 'message', + 'span_name', + 'kind', + 'service_name', + 'deployment_environment', + 'trace_id', + 'span_id', + 'parent_span_id', + 'is_exception', + 'exception_type', + 'exception_message', +].join(', ') + +/** + * Normalize a self-hosted base URL. Assumes HTTPS when no scheme is given and + * drops trailing slashes so endpoint paths concatenate cleanly. A sub-path is + * preserved, so instances served under a prefix keep working. + * + * Rejects anything that would retarget the request once an endpoint path is + * appended — a query string or fragment silently swallows the path, and + * userinfo (`host@elsewhere`) sends the read token to a different origin. + * + * Note the platform only dispatches tool requests to publicly resolvable HTTPS + * hosts, so an instance on a private network or plain HTTP is rejected later by + * the executor regardless of what this accepts. + */ +function normalizeHost(host?: string): string | undefined { + const trimmed = host?.trim() + if (!trimmed) return undefined + + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` + + let parsed: URL + try { + parsed = new URL(withScheme) + } catch { + throw new Error(`Invalid Logfire host: ${trimmed}`) + } + + if (parsed.search || parsed.hash) { + throw new Error('Logfire host must not include a query string or fragment') + } + if (parsed.username || parsed.password) { + throw new Error('Logfire host must not include credentials') + } + + return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '') +} + +/** + * Resolve the API base URL. A self-hosted host wins outright; otherwise an + * explicit region wins; otherwise the region is read from the token prefix. + * Tokens minted before regions existed carry no prefix and are US. + */ +export function getLogfireBaseUrl(apiKey: string, region?: LogfireRegion, host?: string): string { + const selfHosted = normalizeHost(host) + if (selfHosted) return selfHosted + + if (region && region !== 'auto') { + if (!isCloudRegion(region)) { + throw new Error( + `Unrecognized Logfire region '${region}'. Set Region to us or eu, or set Host explicitly.` + ) + } + return REGION_BASE_URLS[region] + } + + const detected = TOKEN_REGION_PATTERN.exec(apiKey?.trim() ?? '')?.[1] + if (!detected) return REGION_BASE_URLS.us + if (!isCloudRegion(detected)) { + throw new Error( + `Unrecognized Logfire token region '${detected}'. Set Region or Host explicitly.` + ) + } + return REGION_BASE_URLS[detected] +} + +/** + * Resolve the full endpoint URL for a tool request. + * + * Note the `apiKey`/`region`/`host` param declarations are deliberately repeated + * in each tool rather than spread from a shared constant: the docs generator + * parses each `params` object literal statically, so a spread would drop those + * three rows from every generated input table. + */ +export const logfireUrl = (params: LogfireBaseParams, path: string): string => + `${getLogfireBaseUrl(params.apiKey, params.region, params.host)}${path}` + +export const logfireHeaders = (apiKey: string): Record => ({ + Authorization: `Bearer ${apiKey.trim()}`, + 'Content-Type': 'application/json', + Accept: 'application/json', +}) + +export const cleanOptionalString = (value?: string): string | undefined => { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +/** Escape a value for use as a single-quoted SQL string literal. */ +export const sqlLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'` + +/** + * Case-insensitive literal substring match. + * + * Uses DataFusion's `contains` rather than `LIKE`/`ILIKE` so `%` and `_` in user + * input stay literal without needing a second escaping pass on top of + * {@link sqlLiteral}. `contains` matches literally as of DataFusion 43 (earlier + * versions compiled the needle as a regex) and is case-sensitive, hence + * `lower()` on both sides. + */ +export const sqlContains = (column: string, value: string): string => + `contains(lower(${column}), ${sqlLiteral(value.toLowerCase())})` + +/** Matches a trailing UTC designator or numeric offset, e.g. `Z`, `+00:00`, `-0530`. */ +const TIMESTAMP_OFFSET_PATTERN = /(?:[Zz]|[+-]\d{2}:?\d{2})$/ +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Logfire rejects a naive timestamp — the value must carry an offset. Assume UTC + * when none is given, matching the official SDK, and widen a bare date to + * midnight so a date-only input is accepted rather than failing the query. + */ +function normalizeTimestamp(value?: string): string | undefined { + const trimmed = cleanOptionalString(value) + if (!trimmed) return undefined + if (DATE_ONLY_PATTERN.test(trimmed)) return `${trimmed}T00:00:00Z` + return TIMESTAMP_OFFSET_PATTERN.test(trimmed) ? trimmed : `${trimmed}Z` +} + +interface LogfireQueryBodyInput { + sql: string + minTimestamp?: string + maxTimestamp?: string + limit?: number + timezone?: string + environment?: string +} + +export function buildLogfireQueryBody(input: LogfireQueryBodyInput): Record { + const body: Record = { + sql: input.sql, + min_timestamp: normalizeTimestamp(input.minTimestamp) ?? LOGFIRE_MIN_TIMESTAMP_FALLBACK, + include_schema: true, + } + + const maxTimestamp = normalizeTimestamp(input.maxTimestamp) + if (maxTimestamp) body.max_timestamp = maxTimestamp + + if (input.limit !== undefined && Number.isFinite(input.limit)) { + body.limit = Math.min(Math.max(Math.trunc(input.limit), LOGFIRE_MIN_LIMIT), LOGFIRE_MAX_LIMIT) + } + + const timezone = cleanOptionalString(input.timezone) + if (timezone) body.timezone = timezone + + const environment = cleanOptionalString(input.environment) + if (environment) body.deployment_environment = [environment] + + return body +} + +/** + * Read a successful Logfire body. Non-OK responses never reach here — the tool + * executor surfaces those before `transformResponse` runs. + */ +export async function parseLogfireResponse(response: Response): Promise { + try { + return (await response.json()) as T + } catch { + throw new Error('Logfire returned a response that is not valid JSON') + } +} + +const asString = (value: unknown): string | null => (typeof value === 'string' ? value : null) +const asNumber = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null +const asBoolean = (value: unknown): boolean | null => (typeof value === 'boolean' ? value : null) + +export const extractLogfireRows = (results: LogfireQueryApiResponse): Record[] => + results.data ?? [] + +export const extractLogfireColumns = (results: LogfireQueryApiResponse): LogfireColumn[] => + results.schema?.fields?.map((field) => ({ + name: field.name ?? null, + datatype: field.data_type ?? null, + nullable: field.nullable ?? null, + })) ?? [] + +export const normalizeLogfireRecord = (row: Record): LogfireRecord => ({ + startTimestamp: asString(row.start_timestamp), + endTimestamp: asString(row.end_timestamp), + duration: asNumber(row.duration), + level: asString(row.level), + message: asString(row.message), + spanName: asString(row.span_name), + kind: asString(row.kind), + serviceName: asString(row.service_name), + deploymentEnvironment: asString(row.deployment_environment), + traceId: asString(row.trace_id), + spanId: asString(row.span_id), + parentSpanId: asString(row.parent_span_id), + isException: asBoolean(row.is_exception), + exceptionType: asString(row.exception_type), + exceptionMessage: asString(row.exception_message), +}) diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 1a1fa494ae6..3e75c9aae78 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2351,6 +2351,12 @@ import { linqUpdateWebhookSubscriptionTool, } from '@/tools/linq' import { llmChatTool } from '@/tools/llm' +import { + logfireGetTokenInfoTool, + logfireGetTraceTool, + logfireQueryTool, + logfireSearchRecordsTool, +} from '@/tools/logfire' import { logsGetExecutionTool, logsGetRunDetailsTool, @@ -5158,6 +5164,10 @@ export const tools: Record = { linq_update_chat: linqUpdateChatTool, linq_update_contact_card: linqUpdateContactCardTool, linq_update_webhook_subscription: linqUpdateWebhookSubscriptionTool, + logfire_query: logfireQueryTool, + logfire_search_records: logfireSearchRecordsTool, + logfire_get_trace: logfireGetTraceTool, + logfire_get_token_info: logfireGetTokenInfoTool, logs_query: logsQueryTool, logs_query_runs: logsQueryRunsTool, logs_get: logsGetTool,