Skip to content

Commit 80ed39e

Browse files
committed
fix(logfire): honor numeric-string limits and surface token validity fields
- accept a numeric-string limit so agent-invoked calls stop silently falling back to Logfire's 100-row default - keep an hour-only UTC offset intact instead of producing +05Z - surface expiresAt and spendingCapReachedAt on Get Token Info - document pending_span as a fourth record kind
1 parent 499e1fd commit 80ed39e

7 files changed

Lines changed: 128 additions & 15 deletions

File tree

apps/docs/content/docs/en/integrations/logfire.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ Search Logfire spans and logs using structured filters for message text, service
9595
|`level` | string | Severity name, such as info, warn, or error |
9696
|`message` | string | Human-readable message |
9797
|`spanName` | string | Template label for similar records |
98-
|`kind` | string | Record kind: span, log, or span_event |
98+
|`kind` | string | Record kind: span, log, span_event, or pending_span |
9999
|`serviceName` | string | Service that emitted the record |
100100
|`deploymentEnvironment` | string | Deployment environment of the record |
101101
|`traceId` | string | Trace this record belongs to |
@@ -134,7 +134,7 @@ Fetch every span and log belonging to a Logfire trace, ordered from earliest to
134134
|`level` | string | Severity name, such as info, warn, or error |
135135
|`message` | string | Human-readable message |
136136
|`spanName` | string | Template label for similar records |
137-
|`kind` | string | Record kind: span, log, or span_event |
137+
|`kind` | string | Record kind: span, log, span_event, or pending_span |
138138
|`serviceName` | string | Service that emitted the record |
139139
|`deploymentEnvironment` | string | Deployment environment of the record |
140140
|`traceId` | string | Trace this record belongs to |
@@ -164,5 +164,7 @@ Resolve which Logfire organization and project a read token belongs to. Useful f
164164
| --------- | ---- | ----------- |
165165
| `organizationName` | string | Logfire organization the read token belongs to |
166166
| `projectName` | string | Logfire project the read token belongs to |
167+
| `expiresAt` | string | When the read token expires. Null when it never expires. |
168+
| `spendingCapReachedAt` | string | When the organization's spending cap was reached, which stops queries. Null when it has not been reached. |
167169

168170

apps/sim/blocks/blocks/logfire.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ Return ONLY the IANA timezone string - no explanations or quotes.`,
292292
host: params.host,
293293
}
294294

295-
const window = {
295+
const timeWindow = {
296296
minTimestamp: params.minTimestamp,
297297
maxTimestamp: params.maxTimestamp,
298298
limit: toNumber(params.limit),
@@ -302,7 +302,7 @@ Return ONLY the IANA timezone string - no explanations or quotes.`,
302302
case 'logfire_query':
303303
return {
304304
...baseParams,
305-
...window,
305+
...timeWindow,
306306
sql: params.sql,
307307
timezone: params.timezone,
308308
environment: params.environment,
@@ -311,7 +311,7 @@ Return ONLY the IANA timezone string - no explanations or quotes.`,
311311
case 'logfire_get_trace':
312312
return {
313313
...baseParams,
314-
...window,
314+
...timeWindow,
315315
traceId: params.traceId,
316316
}
317317

@@ -321,7 +321,7 @@ Return ONLY the IANA timezone string - no explanations or quotes.`,
321321
default:
322322
return {
323323
...baseParams,
324-
...window,
324+
...timeWindow,
325325
query: params.query,
326326
service: params.service,
327327
spanName: params.spanName,
@@ -389,6 +389,17 @@ Return ONLY the IANA timezone string - no explanations or quotes.`,
389389
description: 'Project the read token belongs to',
390390
condition: { field: 'operation', value: 'logfire_get_token_info' },
391391
},
392+
expiresAt: {
393+
type: 'string',
394+
description: 'When the read token expires. Null when it never expires.',
395+
condition: { field: 'operation', value: 'logfire_get_token_info' },
396+
},
397+
spendingCapReachedAt: {
398+
type: 'string',
399+
description:
400+
"When the organization's spending cap was reached, which stops queries. Null when it has not been reached.",
401+
condition: { field: 'operation', value: 'logfire_get_token_info' },
402+
},
392403
},
393404
}
394405

apps/sim/tools/logfire/get_token_info.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,52 @@ describe('logfireGetTokenInfoTool request', () => {
2424
describe('logfireGetTokenInfoTool transformResponse', () => {
2525
it('maps the snake_case token info into the block outputs', async () => {
2626
const response = new Response(
27-
JSON.stringify({ organization_name: 'acme', project_name: 'backend' }),
27+
JSON.stringify({
28+
organization_name: 'acme',
29+
project_name: 'backend',
30+
expires_at: '2027-01-01T00:00:00Z',
31+
spending_cap_reached_at: '2026-08-01T12:00:00Z',
32+
}),
2833
{ status: 200 }
2934
)
3035

3136
const result = await logfireGetTokenInfoTool.transformResponse?.(response, baseParams)
3237

3338
expect(result?.success).toBe(true)
34-
expect(result?.output).toEqual({ organizationName: 'acme', projectName: 'backend' })
39+
expect(result?.output).toEqual({
40+
organizationName: 'acme',
41+
projectName: 'backend',
42+
expiresAt: '2027-01-01T00:00:00Z',
43+
spendingCapReachedAt: '2026-08-01T12:00:00Z',
44+
})
45+
})
46+
47+
it('keeps a non-expiring, under-cap token distinguishable from a missing field', async () => {
48+
const response = new Response(
49+
JSON.stringify({
50+
organization_name: 'acme',
51+
project_name: 'backend',
52+
expires_at: null,
53+
spending_cap_reached_at: null,
54+
}),
55+
{ status: 200 }
56+
)
57+
58+
const result = await logfireGetTokenInfoTool.transformResponse?.(response, baseParams)
59+
60+
expect(result?.output.expiresAt).toBeNull()
61+
expect(result?.output.spendingCapReachedAt).toBeNull()
3562
})
3663

3764
it('nulls fields the API omitted', async () => {
3865
const response = new Response(JSON.stringify({}), { status: 200 })
3966
const result = await logfireGetTokenInfoTool.transformResponse?.(response, baseParams)
4067

41-
expect(result?.output).toEqual({ organizationName: null, projectName: null })
68+
expect(result?.output).toEqual({
69+
organizationName: null,
70+
projectName: null,
71+
expiresAt: null,
72+
spendingCapReachedAt: null,
73+
})
4274
})
4375
})

apps/sim/tools/logfire/get_token_info.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export const logfireGetTokenInfoTool: ToolConfig<
5757
output: {
5858
organizationName: info.organization_name ?? null,
5959
projectName: info.project_name ?? null,
60+
expiresAt: info.expires_at ?? null,
61+
spendingCapReachedAt: info.spending_cap_reached_at ?? null,
6062
},
6163
}
6264
},
@@ -72,5 +74,16 @@ export const logfireGetTokenInfoTool: ToolConfig<
7274
description: 'Logfire project the read token belongs to',
7375
nullable: true,
7476
},
77+
expiresAt: {
78+
type: 'string',
79+
description: 'When the read token expires. Null when it never expires.',
80+
nullable: true,
81+
},
82+
spendingCapReachedAt: {
83+
type: 'string',
84+
description:
85+
"When the organization's spending cap was reached, which stops queries. Null when it has not been reached.",
86+
nullable: true,
87+
},
7588
},
7689
}

apps/sim/tools/logfire/types.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,19 @@ export interface LogfireQueryApiResponse {
5858
data?: Record<string, unknown>[] | null
5959
}
6060

61-
/** Shape of `GET /v1/read-token-info`. */
61+
/**
62+
* Shape of `GET /v1/read-token-info`. The endpoint is not covered by the public
63+
* API reference; these fields are taken from the official SDK's `ReadTokenInfo`
64+
* and from Logfire's own recorded response fixtures. It returns further billing
65+
* and ownership internals that are deliberately not surfaced.
66+
*/
6267
export interface LogfireReadTokenInfo {
6368
organization_name?: string | null
6469
project_name?: string | null
70+
/** Null when the token never expires. */
71+
expires_at?: string | null
72+
/** Set once the organization's spending cap has been hit, which stops queries. */
73+
spending_cap_reached_at?: string | null
6574
}
6675

6776
/**
@@ -111,7 +120,7 @@ export const LOGFIRE_RECORD_OUTPUT_ITEM: NonNullable<ToolOutputProperty['items']
111120
spanName: { type: 'string', description: 'Template label for similar records', nullable: true },
112121
kind: {
113122
type: 'string',
114-
description: 'Record kind: span, log, or span_event',
123+
description: 'Record kind: span, log, span_event, or pending_span',
115124
nullable: true,
116125
},
117126
serviceName: { type: 'string', description: 'Service that emitted the record', nullable: true },
@@ -182,6 +191,8 @@ export interface LogfireGetTokenInfoResponse extends ToolResponse {
182191
output: {
183192
organizationName: string | null
184193
projectName: string | null
194+
expiresAt: string | null
195+
spendingCapReachedAt: string | null
185196
}
186197
}
187198

apps/sim/tools/logfire/utils.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,36 @@ describe('buildLogfireQueryBody', () => {
188188
})
189189
})
190190

191+
it('preserves an hour-only UTC offset instead of appending a second designator', () => {
192+
expect(
193+
buildLogfireQueryBody({ sql: 'SELECT 1', minTimestamp: '2026-07-01T00:00:00+05' })
194+
).toMatchObject({
195+
min_timestamp: '2026-07-01T00:00:00+05',
196+
})
197+
})
198+
191199
it('clamps limit into the range Logfire accepts', () => {
192200
expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: 50 }).limit).toBe(50)
193201
expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: 99999 }).limit).toBe(10000)
194202
expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: 0 }).limit).toBe(1)
195203
expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: 12.7 }).limit).toBe(12)
196204
})
197205

206+
it('accepts a numeric string limit, which agents calling the tool directly emit', () => {
207+
expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: '50' as never }).limit).toBe(50)
208+
expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: '99999' as never }).limit).toBe(10000)
209+
})
210+
211+
it('omits limit entirely when it is absent or not a number', () => {
212+
expect(buildLogfireQueryBody({ sql: 'SELECT 1' })).not.toHaveProperty('limit')
213+
expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: '' as never })).not.toHaveProperty(
214+
'limit'
215+
)
216+
expect(buildLogfireQueryBody({ sql: 'SELECT 1', limit: 'fifty' as never })).not.toHaveProperty(
217+
'limit'
218+
)
219+
})
220+
198221
it('sends deployment_environment as an array', () => {
199222
expect(buildLogfireQueryBody({ sql: 'SELECT 1', environment: 'production' })).toMatchObject({
200223
deployment_environment: ['production'],

apps/sim/tools/logfire/utils.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,13 @@ export const sqlLiteral = (value: string): string => `'${value.replace(/'/g, "''
159159
export const sqlContains = (column: string, value: string): string =>
160160
`contains(lower(${column}), ${sqlLiteral(value.toLowerCase())})`
161161

162-
/** Matches a trailing UTC designator or numeric offset, e.g. `Z`, `+00:00`, `-0530`. */
163-
const TIMESTAMP_OFFSET_PATTERN = /(?:[Zz]|[+-]\d{2}:?\d{2})$/
162+
/**
163+
* Matches a trailing UTC designator or numeric offset, e.g. `Z`, `+00:00`,
164+
* `-0530`, `+05`. The minute component is optional because ISO 8601 permits an
165+
* hour-only offset, and appending `Z` to one would produce `+05Z`, which no
166+
* parser accepts.
167+
*/
168+
const TIMESTAMP_OFFSET_PATTERN = /(?:[Zz]|[+-]\d{2}(?::?\d{2})?)$/
164169
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
165170

166171
/**
@@ -175,6 +180,21 @@ function normalizeTimestamp(value?: string): string | undefined {
175180
return TIMESTAMP_OFFSET_PATTERN.test(trimmed) ? trimmed : `${trimmed}Z`
176181
}
177182

183+
/**
184+
* Coerce a row limit that may arrive as a numeric string. The block already
185+
* converts its text input, but tools are also callable directly by agents,
186+
* which routinely emit numbers as JSON strings — dropping those silently would
187+
* fall back to Logfire's default of 100 rows instead of the requested limit.
188+
*/
189+
const toFiniteNumber = (value: unknown): number | undefined => {
190+
if (typeof value === 'number' && Number.isFinite(value)) return value
191+
if (typeof value === 'string' && value.trim()) {
192+
const parsed = Number(value)
193+
if (Number.isFinite(parsed)) return parsed
194+
}
195+
return undefined
196+
}
197+
178198
interface LogfireQueryBodyInput {
179199
sql: string
180200
minTimestamp?: string
@@ -194,8 +214,9 @@ export function buildLogfireQueryBody(input: LogfireQueryBodyInput): Record<stri
194214
const maxTimestamp = normalizeTimestamp(input.maxTimestamp)
195215
if (maxTimestamp) body.max_timestamp = maxTimestamp
196216

197-
if (input.limit !== undefined && Number.isFinite(input.limit)) {
198-
body.limit = Math.min(Math.max(Math.trunc(input.limit), LOGFIRE_MIN_LIMIT), LOGFIRE_MAX_LIMIT)
217+
const limit = toFiniteNumber(input.limit)
218+
if (limit !== undefined) {
219+
body.limit = Math.min(Math.max(Math.trunc(limit), LOGFIRE_MIN_LIMIT), LOGFIRE_MAX_LIMIT)
199220
}
200221

201222
const timezone = cleanOptionalString(input.timezone)

0 commit comments

Comments
 (0)