6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
275 lines
9.6 KiB
TypeScript
275 lines
9.6 KiB
TypeScript
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<string, string> => ({
|
|
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`, `+05`. The minute component is optional because ISO 8601 permits an
|
|
* hour-only offset, and appending `Z` to one would produce `+05Z`, which no
|
|
* parser accepts.
|
|
*/
|
|
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`
|
|
}
|
|
|
|
/**
|
|
* Coerce a row limit that may arrive as a numeric string. The block already
|
|
* converts its text input, but tools are also callable directly by agents,
|
|
* which routinely emit numbers as JSON strings — dropping those silently would
|
|
* fall back to Logfire's default of 100 rows instead of the requested limit.
|
|
*/
|
|
const toFiniteNumber = (value: unknown): number | undefined => {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
if (typeof value === 'string' && value.trim()) {
|
|
const parsed = Number(value)
|
|
if (Number.isFinite(parsed)) return parsed
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
interface LogfireQueryBodyInput {
|
|
sql: string
|
|
minTimestamp?: string
|
|
maxTimestamp?: string
|
|
limit?: number
|
|
timezone?: string
|
|
environment?: string
|
|
}
|
|
|
|
export function buildLogfireQueryBody(input: LogfireQueryBodyInput): Record<string, unknown> {
|
|
const body: Record<string, unknown> = {
|
|
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
|
|
|
|
const limit = toFiniteNumber(input.limit)
|
|
if (limit !== undefined) {
|
|
body.limit = Math.min(Math.max(Math.trunc(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<T>(response: Response): Promise<T> {
|
|
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<string, unknown>[] =>
|
|
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<string, unknown>): 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),
|
|
})
|