Skip to content

Commit 71f46db

Browse files
committed
Automatically close the spans started with logfire.span
Include metadata about what was scrubbed
1 parent 1153908 commit 71f46db

File tree

6 files changed

+80
-35
lines changed

6 files changed

+80
-35
lines changed

.changeset/weak-dryers-scream.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"logfire": minor
3+
"@pydantic/logfire-api": minor
4+
"@pydantic/logfire-browser": minor
5+
"@pydantic/logfire-cf-workers": minor
6+
---
7+
8+
Auto-close spans opened with logfire.span

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,6 @@ export default async function Home() {
180180
level: logfire.Level.Warning,
181181
}, async (span) => {
182182
logfire.info("Nested info span");
183-
// ending the span is necessary to ensure it is reported
184-
span.end();
185183
return <div>Hello</div>;
186184
});
187185
}

packages/logfire-api/src/AttributeScrubber.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
export type JsonPath = (number | string)[]
44
export interface ScrubbedNote {
5-
matchedSubstring: string
5+
matched_substring: string
66
path: JsonPath
77
}
88

@@ -136,7 +136,7 @@ export class LogfireAttributeScrubber implements BaseScrubber {
136136
}
137137

138138
const matchedSubstring = match[0]
139-
notes.push({ matchedSubstring, path })
139+
notes.push({ matched_substring: matchedSubstring, path })
140140
return `[Scrubbed due to '${matchedSubstring}']`
141141
}
142142

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { trace } from '@opentelemetry/api'
2-
import { expect, test, vi } from 'vitest'
2+
import { beforeEach, describe, expect, test, vi } from 'vitest'
33

44
import { ATTRIBUTES_LEVEL_KEY, ATTRIBUTES_MESSAGE_TEMPLATE_KEY, ATTRIBUTES_SPAN_TYPE_KEY, ATTRIBUTES_TAGS_KEY } from './constants'
55
import { info } from './index'
@@ -25,22 +25,50 @@ vi.mock('@opentelemetry/api', () => {
2525
}
2626
})
2727

28-
test('formats the message with the passed attributes', () => {
29-
info('aha {i}', { i: 1 })
30-
const tracer = trace.getTracer('logfire')
31-
32-
// eslint-disable-next-line @typescript-eslint/unbound-method
33-
expect(tracer.startSpan).toBeCalledWith(
34-
'aha 1',
35-
{
36-
attributes: {
37-
[ATTRIBUTES_LEVEL_KEY]: 9,
38-
[ATTRIBUTES_MESSAGE_TEMPLATE_KEY]: 'aha {i}',
39-
[ATTRIBUTES_SPAN_TYPE_KEY]: 'log',
40-
[ATTRIBUTES_TAGS_KEY]: [],
41-
i: 1,
28+
describe('info', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks()
31+
})
32+
test('formats the message with the passed attributes', () => {
33+
info('aha {i}', { i: 1 })
34+
const tracer = trace.getTracer('logfire')
35+
36+
// eslint-disable-next-line @typescript-eslint/unbound-method
37+
expect(tracer.startSpan).toBeCalledWith(
38+
'aha 1',
39+
{
40+
attributes: {
41+
[ATTRIBUTES_LEVEL_KEY]: 9,
42+
[ATTRIBUTES_MESSAGE_TEMPLATE_KEY]: 'aha {i}',
43+
[ATTRIBUTES_SPAN_TYPE_KEY]: 'log',
44+
[ATTRIBUTES_TAGS_KEY]: [],
45+
i: 1,
46+
},
4247
},
43-
},
44-
undefined
45-
)
48+
undefined
49+
)
50+
})
51+
52+
test('adds scrubbing details', () => {
53+
info('aha {i}', { i: 1, password: 'hunter' })
54+
const tracer = trace.getTracer('logfire')
55+
56+
// eslint-disable-next-line @typescript-eslint/unbound-method
57+
expect(tracer.startSpan).toBeCalledWith(
58+
'aha 1',
59+
{
60+
attributes: {
61+
[ATTRIBUTES_LEVEL_KEY]: 9,
62+
[ATTRIBUTES_MESSAGE_TEMPLATE_KEY]: 'aha {i}',
63+
[ATTRIBUTES_SPAN_TYPE_KEY]: 'log',
64+
[ATTRIBUTES_TAGS_KEY]: [],
65+
i: 1,
66+
'logfire.json_schema': '{"properties":{"logfire.scrubbed":{"type":"array"}},"type":"object"}',
67+
'logfire.scrubbed': '[{"matched_substring":"password","path":["password"]}]',
68+
password: "[Scrubbed due to 'password']",
69+
},
70+
},
71+
undefined
72+
)
73+
})
4674
})

packages/logfire-api/src/index.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,12 @@ export function startSpan(
4848
attributes: Record<string, unknown> = {},
4949
{ log, tags = [], level = Level.Info }: LogOptions = {}
5050
): Span {
51-
// TODO: should we also send the extra attributes (2nd arg)?
52-
const [formattedMessage, , newTemplate] = logfireFormatWithExtras(msgTemplate, attributes, logfireApiConfig.scrubber)
51+
const [formattedMessage, extraAttributes, newTemplate] = logfireFormatWithExtras(msgTemplate, attributes, logfireApiConfig.scrubber)
5352
const span = logfireApiConfig.tracer.startSpan(
5453
formattedMessage,
5554
{
5655
attributes: {
57-
...serializeAttributes(attributes),
56+
...serializeAttributes({ ...attributes, ...extraAttributes }),
5857
[ATTRIBUTES_MESSAGE_TEMPLATE_KEY]: newTemplate,
5958
[ATTRIBUTES_LEVEL_KEY]: level,
6059
[ATTRIBUTES_TAGS_KEY]: Array.from(new Set(tags).values()),
@@ -67,24 +66,29 @@ export function startSpan(
6766
return span
6867
}
6968

70-
export function span<F extends (span: Span) => unknown>(
69+
export function span<R>(
7170
msgTemplate: string,
7271
attributes: Record<string, unknown> = {},
7372
{ tags = [], level = Level.Info }: LogOptions = {},
74-
callback: F
73+
callback: (span: Span) => R
7574
) {
76-
const [formattedMessage, , newTemplate] = logfireFormatWithExtras(msgTemplate, attributes, logfireApiConfig.scrubber)
77-
return logfireApiConfig.tracer.startActiveSpan<F>(
75+
const [formattedMessage, extraAttributes, newTemplate] = logfireFormatWithExtras(msgTemplate, attributes, logfireApiConfig.scrubber)
76+
77+
return logfireApiConfig.tracer.startActiveSpan(
7878
formattedMessage,
7979
{
8080
attributes: {
81-
...serializeAttributes(attributes),
81+
...serializeAttributes({ ...attributes, ...extraAttributes }),
8282
[ATTRIBUTES_MESSAGE_TEMPLATE_KEY]: newTemplate,
8383
[ATTRIBUTES_LEVEL_KEY]: level,
8484
[ATTRIBUTES_TAGS_KEY]: Array.from(new Set(tags).values()),
8585
},
8686
},
87-
callback
87+
(span: Span) => {
88+
const result = callback(span)
89+
span.end()
90+
return result
91+
}
8892
)
8993
}
9094

packages/logfire-api/src/serializeAttributes.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { logfireApiConfig } from '.'
2-
import { ATTRIBUTES_SPAN_TYPE_KEY, ATTRIBUTES_TAGS_KEY, JSON_NULL_FIELDS_KEY, JSON_SCHEMA_KEY } from './constants'
1+
import { logfireApiConfig, ScrubbedNote } from '.'
2+
import { ATTRIBUTES_SCRUBBED_KEY, ATTRIBUTES_SPAN_TYPE_KEY, ATTRIBUTES_TAGS_KEY, JSON_NULL_FIELDS_KEY, JSON_SCHEMA_KEY } from './constants'
33

44
export type AttributeValue = boolean | number | string | string[]
55

@@ -20,12 +20,19 @@ type SerializedAttributes = Record<string, AttributeValue>
2020
export function serializeAttributes(attributes: RawAttributes): SerializedAttributes {
2121
const scrubber = logfireApiConfig.scrubber
2222
const alreadyScubbed = ATTRIBUTES_SPAN_TYPE_KEY in attributes
23-
const scrubbedAttributes = alreadyScubbed ? attributes : (scrubber.scrubValue([], attributes)[0] as Record<string, unknown>)
24-
// if the span is created through the logfire API methods, the attributes have already been scrubbed
23+
const [scrubbedAttributes, scrubNotes] = alreadyScubbed ? [attributes, []] : scrubber.scrubValue([], attributes)
2524

2625
const result: SerializedAttributes = {}
2726
const nullArgs: string[] = []
2827
const schema: JSONSchema = { properties: {}, type: 'object' }
28+
29+
if (scrubNotes.length > 0) {
30+
if (ATTRIBUTES_SCRUBBED_KEY in scrubbedAttributes) {
31+
;(scrubbedAttributes[ATTRIBUTES_SCRUBBED_KEY] as ScrubbedNote[]).push(...scrubNotes)
32+
} else {
33+
scrubbedAttributes[ATTRIBUTES_SCRUBBED_KEY] = scrubNotes
34+
}
35+
}
2936
for (const [key, value] of Object.entries(scrubbedAttributes)) {
3037
// we don't want to serialize the tags
3138
if (key === ATTRIBUTES_TAGS_KEY) {

0 commit comments

Comments
 (0)