Skip to content

Commit 7679de6

Browse files
ablaszkiewiczclaude
andcommitted
feat(error-tracking): scroll through a frame's file at the release commit
An expanded in-app frame with a repo path shows its file from GitHub at the release commit in a fixed-height box centered on the frame line, and shows more lines as you scroll to either edge. The server reads the file path, line, repository and commit from the stored event and its release, checks the file against the captured code line, and caches files for a day. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 0da4ca8 commit 7679de6

17 files changed

Lines changed: 991 additions & 8 deletions

‎.github/new-events-schema-targets.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ posthog/clickhouse/test
2727
ee/clickhouse
2828
products/customer_analytics/backend/hogql_queries
2929
products/error_tracking/backend/hogql_queries
30+
products/error_tracking/backend/tests/api/test_git_provider_file_links.py
3031
products/marketing_analytics/backend/hogql_queries
3132
products/mcp_analytics/backend/tests/test_dashboard_series.py
3233
products/mcp_analytics/backend/tests/test_harness_breakdown.py

‎frontend/src/lib/components/Errors/Frame/CollapsibleFrame.stories.tsx‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,61 @@ export function MultipleFrames(): JSX.Element {
295295
</BindLogic>
296296
)
297297
}
298+
299+
const repoFrame: ErrorTrackingStackFrame = { ...baseFrame, raw_id: 'repo-1', repo_path: 'apps/web/src/FrameLoader.ts' }
300+
301+
const sourceFileLines = [
302+
...Array.from({ length: 5 }, (_, i) => `// FrameLoader.ts line ${i + 1}`),
303+
...frameContext.before.map(({ line }) => line),
304+
frameContext.line.line,
305+
...frameContext.after.map(({ line }) => line),
306+
...Array.from({ length: 40 }, (_, i) => `// FrameLoader.ts line ${i + 15}`),
307+
]
308+
309+
export function SourceFileFromRepository(): JSX.Element {
310+
const properties = {
311+
...eventProperties,
312+
$exception_release: {
313+
id: 'release-1',
314+
version: '1.0.0',
315+
timestamp: '2024-01-01T00:00:00Z',
316+
metadata: {
317+
git: {
318+
remote_url: 'https://github.com/acme/web.git',
319+
commit_id: '0123456789abcdef0123456789abcdef01234567',
320+
},
321+
},
322+
},
323+
}
324+
return (
325+
<BindLogic
326+
logic={errorPropertiesLogic}
327+
props={{ properties, id: 'story-source-file', timestamp: '2024-01-01T00:00:00Z' }}
328+
>
329+
<div className="max-w-2xl border rounded">
330+
<CollapsibleFrame
331+
frame={repoFrame}
332+
record={{ ...baseRecord, raw_id: 'repo-1', contents: repoFrame }}
333+
recordLoading={false}
334+
expanded
335+
onExpandedChange={() => {}}
336+
/>
337+
</div>
338+
</BindLogic>
339+
)
340+
}
341+
SourceFileFromRepository.decorators = [
342+
mswDecorator({
343+
get: {
344+
'/api/projects/:team_id/error_tracking/git-provider-file-links/source_file/': {
345+
repo_path: 'apps/web/src/FrameLoader.ts',
346+
commit: '0123456789abcdef0123456789abcdef01234567',
347+
line: 11,
348+
lines: sourceFileLines,
349+
},
350+
},
351+
}),
352+
]
353+
SourceFileFromRepository.parameters = {
354+
testOptions: { waitForSelector: '[data-attr="error-tracking-frame-source-file"]' },
355+
}

‎frontend/src/lib/components/Errors/Frame/CollapsibleFrameContent.tsx‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import { useValues } from 'kea'
2+
13
import { Collapsible } from 'lib/ui/Collapsible/Collapsible'
24

5+
import { errorPropertiesLogic } from '../errorPropertiesLogic'
36
import { ErrorTrackingStackFrame, ErrorTrackingStackFrameContext, ErrorTrackingStackFrameRecord } from '../types'
47
import { CodeVariablesInlineBanner } from './CodeVariablesInlineBanner'
58
import { FrameContext } from './FrameContext'
69
import { getFrameLanguage } from './frameLanguage'
10+
import { FrameSourceFile } from './FrameSourceFile'
711
import { FrameVariables } from './FrameVariables'
812

913
export interface CollapsibleFrameContentProps {
@@ -19,14 +23,22 @@ export function CollapsibleFrameContent({
1923
onFrameContextClick,
2024
}: CollapsibleFrameContentProps): JSX.Element | null {
2125
const { code_variables } = frame
26+
const { getSourceFileTarget } = useValues(errorPropertiesLogic)
2227
const hasCodeVariables = code_variables && Object.keys(code_variables).length > 0
23-
if (!record || !record.context) {
28+
const context = record?.context ?? null
29+
const sourceFileTarget = getSourceFileTarget(frame)
30+
if (!context && !sourceFileTarget) {
2431
return null
2532
}
33+
const language = getFrameLanguage(frame)
2634
return (
2735
<Collapsible.Panel className="border-t-[color:var(--frame-border,var(--color-border-primary))]">
28-
<div onClick={(e) => onFrameContextClick?.(record.context!, e)}>
29-
<FrameContext context={record.context} language={getFrameLanguage(frame)} />
36+
<div onClick={(e) => context && onFrameContextClick?.(context, e)}>
37+
{sourceFileTarget ? (
38+
<FrameSourceFile target={sourceFileTarget} context={context} language={language} />
39+
) : (
40+
<FrameContext context={context!} language={language} />
41+
)}
3042
{hasCodeVariables ? <FrameVariables variables={code_variables!} /> : <CodeVariablesInlineBanner />}
3143
</div>
3244
</Collapsible.Panel>

‎frontend/src/lib/components/Errors/Frame/CollapsibleFrameHeader.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export function CollapsibleFrameHeader({
3131
expanded: boolean
3232
}): JSX.Element {
3333
const { raw_id, source, line, column, resolved, resolve_failure, in_app, lang } = frame
34-
const { getFrameFingerprint } = useValues(errorPropertiesLogic)
34+
const { getFrameFingerprint, getSourceFileTarget } = useValues(errorPropertiesLogic)
35+
const canReadSourceFile = !!getSourceFileTarget(frame)
3536

3637
const part = getFrameFingerprint(raw_id)
3738
const functionName = formatFunctionName(frame)
@@ -68,7 +69,7 @@ export function CollapsibleFrameHeader({
6869
className={cn('collapsible-frame-header grow max-w-[calc(100%-30px)]', {
6970
'cursor-progress': recordLoading,
7071
})}
71-
disabled={!hasRecordContext && !recordLoading}
72+
disabled={!hasRecordContext && !recordLoading && !canReadSourceFile}
7273
>
7374
{functionName && (
7475
<span ref={functionRef} className="font-medium frame-function" title={functionName}>
@@ -99,7 +100,9 @@ export function CollapsibleFrameHeader({
99100
<UnresolvedIcon resolve_failure={resolve_failure} />
100101
))
101102
.with([true, true, true, false], () => <SpinnerIcon />)
102-
.with([true, true, false, false], () => <NoContextIcon lang={lang} raw_id={raw_id} />)
103+
.with([true, true, false, false], () =>
104+
canReadSourceFile ? null : <NoContextIcon lang={lang} raw_id={raw_id} />
105+
)
103106
.otherwise(() => null)
104107
)}
105108
</div>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { useActions, useValues } from 'kea'
2+
import { useLayoutEffect, useRef } from 'react'
3+
4+
import { Language } from 'lib/components/CodeSnippet'
5+
import { Spinner } from 'lib/lemon-ui/Spinner'
6+
7+
import { ErrorTrackingStackFrameContext } from '../types'
8+
import { FrameContext } from './FrameContext'
9+
import { FrameSourceFileTarget, frameSourceFileLogic } from './frameSourceFileLogic'
10+
11+
// How close to an edge of the box, in pixels, a scroll shows more lines.
12+
const EDGE_PX = 48
13+
14+
export function FrameSourceFile({
15+
target,
16+
context,
17+
language,
18+
}: {
19+
target: FrameSourceFileTarget
20+
context: ErrorTrackingStackFrameContext | null
21+
language: Language
22+
}): JSX.Element {
23+
const logic = frameSourceFileLogic({ ...target, capturedLine: context?.line.line ?? null })
24+
const { sourceFile, sourceFileLoading, fileWindow, capturedCodeCheck } = useValues(logic)
25+
const { showMoreAbove, showMoreBelow } = useActions(logic)
26+
const boxRef = useRef<HTMLDivElement>(null)
27+
const heightBeforeLinesAbove = useRef<number | null>(null)
28+
const centered = useRef(false)
29+
30+
useLayoutEffect(() => {
31+
const box = boxRef.current
32+
if (!box || !fileWindow || centered.current) {
33+
return
34+
}
35+
const { before, after } = fileWindow.context
36+
const rowHeight = box.scrollHeight / (before.length + 1 + after.length)
37+
box.scrollTop = rowHeight * before.length - (box.clientHeight - rowHeight) / 2
38+
centered.current = true
39+
}, [fileWindow])
40+
41+
// Lines added above would push the visible lines down, so the scroll moves by the same height.
42+
const linesBefore = fileWindow?.context.before.length
43+
useLayoutEffect(() => {
44+
const box = boxRef.current
45+
if (box && heightBeforeLinesAbove.current !== null) {
46+
box.scrollTop += box.scrollHeight - heightBeforeLinesAbove.current
47+
heightBeforeLinesAbove.current = null
48+
}
49+
}, [linesBefore])
50+
51+
const onScroll = (): void => {
52+
const box = boxRef.current
53+
if (!box || !fileWindow) {
54+
return
55+
}
56+
if (box.scrollTop < EDGE_PX && fileWindow.hasMoreAbove && heightBeforeLinesAbove.current === null) {
57+
heightBeforeLinesAbove.current = box.scrollHeight
58+
showMoreAbove()
59+
} else if (box.scrollHeight - box.scrollTop - box.clientHeight < EDGE_PX && fileWindow.hasMoreBelow) {
60+
showMoreBelow()
61+
}
62+
}
63+
64+
if (!sourceFile || !fileWindow || capturedCodeCheck === 'differs') {
65+
return (
66+
<>
67+
{context ? (
68+
<FrameContext context={context} language={language} />
69+
) : sourceFileLoading ? (
70+
<div className="p-2">
71+
<Spinner />
72+
</div>
73+
) : (
74+
<p className="m-0 p-2 text-xs text-muted">The source of this frame isn't available.</p>
75+
)}
76+
{capturedCodeCheck === 'differs' && (
77+
<p className="m-0 border-t px-2 py-1 text-xs text-muted">
78+
The file at the release commit doesn't match the captured code, so only the captured lines are
79+
shown.
80+
</p>
81+
)}
82+
</>
83+
)
84+
}
85+
86+
return (
87+
<div>
88+
<div className="flex items-center justify-between gap-2 border-b px-2 py-1 text-xs text-muted">
89+
<span className="truncate font-mono" title={sourceFile.repo_path}>
90+
{sourceFile.repo_path} at {sourceFile.commit.slice(0, 7)}
91+
</span>
92+
{capturedCodeCheck === 'unchecked' && (
93+
<span className="shrink-0">Not compared with the captured code</span>
94+
)}
95+
</div>
96+
<div
97+
ref={boxRef}
98+
onScroll={onScroll}
99+
className="max-h-96 overflow-y-auto"
100+
data-attr="error-tracking-frame-source-file"
101+
>
102+
<FrameContext context={fileWindow.context} language={language} />
103+
</div>
104+
</div>
105+
)
106+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { expectLogic } from 'kea-test-utils'
2+
3+
import { initKeaTests } from '~/test/init'
4+
5+
import { errorTrackingGitProviderFileLinksSourceFileRetrieve } from 'products/error_tracking/frontend/generated/api'
6+
7+
import { frameSourceFileLogic } from './frameSourceFileLogic'
8+
9+
jest.mock('products/error_tracking/frontend/generated/api', () => ({
10+
errorTrackingGitProviderFileLinksSourceFileRetrieve: jest.fn(),
11+
}))
12+
13+
const readSourceFile = errorTrackingGitProviderFileLinksSourceFileRetrieve as jest.Mock
14+
15+
const TARGET = { eventUuid: 'event-1', eventTimestamp: '2026-01-01T00:00:00Z', rawId: 'frame-1/0' }
16+
17+
describe('frameSourceFileLogic', () => {
18+
beforeEach(() => {
19+
initKeaTests()
20+
readSourceFile.mockReset()
21+
})
22+
23+
it('shows more of the file on each scroll to an edge', async () => {
24+
readSourceFile.mockResolvedValue({
25+
repo_path: 'services/api/app/one.py',
26+
commit: '0123456789abcdef0123456789abcdef01234567',
27+
line: 150,
28+
lines: Array.from({ length: 300 }, (_, i) => `line ${i + 1}`),
29+
})
30+
const logic = frameSourceFileLogic({ ...TARGET, capturedLine: 'line 150' })
31+
logic.mount()
32+
await expectLogic(logic).toFinishAllListeners()
33+
34+
expect(readSourceFile.mock.calls[0][1]).toEqual({
35+
event_uuid: 'event-1',
36+
event_timestamp: '2026-01-01T00:00:00Z',
37+
frame_raw_id: 'frame-1/0',
38+
})
39+
expect(logic.values.capturedCodeCheck).toBe('matches')
40+
expect(logic.values.fileWindow?.context.before[0].number).toBe(135)
41+
42+
logic.actions.showMoreAbove()
43+
logic.actions.showMoreBelow()
44+
45+
expect(logic.values.fileWindow?.context.before[0].number).toBe(35)
46+
expect(logic.values.fileWindow?.context.after.at(-1)?.number).toBe(265)
47+
})
48+
49+
it('keeps the captured context when the file cannot be read', async () => {
50+
readSourceFile.mockRejectedValue({ status: 404, detail: 'no_repo_path' })
51+
const logic = frameSourceFileLogic({ ...TARGET, capturedLine: 'line 150' })
52+
logic.mount()
53+
await expectLogic(logic).toFinishAllListeners()
54+
55+
expect(logic.values.sourceFile).toBeNull()
56+
expect(logic.values.fileWindow).toBeNull()
57+
})
58+
})

0 commit comments

Comments
 (0)