Skip to content

Commit f683059

Browse files
authored
feat(svelte): Add Component Tracking (#5612)
Add component tracking to the Sentry Svelte SDK - inject function calls into components by using a preprocessor - add preprocessor and tests
1 parent 27b5771 commit f683059

9 files changed

+464
-1
lines changed

packages/svelte/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@
1919
"@sentry/browser": "7.11.1",
2020
"@sentry/types": "7.11.1",
2121
"@sentry/utils": "7.11.1",
22+
"magic-string": "^0.26.2",
2223
"tslib": "^1.9.3"
2324
},
2425
"peerDependencies": {
2526
"svelte": "3.x"
2627
},
28+
"devDependencies": {
29+
"svelte": "3.49.0"
30+
},
2731
"scripts": {
2832
"build": "run-p build:rollup build:types",
2933
"build:dev": "run-s build",

packages/svelte/rollup.npm.config.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js';
22

3-
export default makeNPMConfigVariants(makeBaseNPMConfig());
3+
export default makeNPMConfigVariants(
4+
makeBaseNPMConfig({
5+
// Prevent 'svelte/internal' stuff from being included in the built JS
6+
packageSpecificConfig: { external: ['svelte/internal'] },
7+
}),
8+
);

packages/svelte/src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const UI_SVELTE_INIT = 'ui.svelte.init';
2+
3+
export const UI_SVELTE_UPDATE = 'ui.svelte.update';
4+
5+
export const DEFAULT_COMPONENT_NAME = 'Svelte Component';

packages/svelte/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
export type {
2+
ComponentTrackingInitOptions as ComponentTrackingOptions,
3+
TrackComponentOptions as TrackingOptions,
4+
} from './types';
5+
16
export * from '@sentry/browser';
27

38
export { init } from './sdk';
9+
10+
export { componentTrackingPreprocessor } from './preprocessors';
11+
export { trackComponent } from './performance';

packages/svelte/src/performance.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { getCurrentHub } from '@sentry/browser';
2+
import { Span, Transaction } from '@sentry/types';
3+
import { afterUpdate, beforeUpdate, onMount } from 'svelte';
4+
import { current_component } from 'svelte/internal';
5+
6+
import { DEFAULT_COMPONENT_NAME, UI_SVELTE_INIT, UI_SVELTE_UPDATE } from './constants';
7+
import { TrackComponentOptions } from './types';
8+
9+
const defaultTrackComponentOptions: {
10+
trackInit: boolean;
11+
trackUpdates: boolean;
12+
componentName?: string;
13+
} = {
14+
trackInit: true,
15+
trackUpdates: true,
16+
};
17+
18+
/**
19+
* Tracks the Svelte component's intialization and mounting operation as well as
20+
* updates and records them as spans.
21+
* This function is injected automatically into your Svelte components' code
22+
* if you are using the Sentry componentTrackingPreprocessor.
23+
* Alternatively, you can call it yourself if you don't want to use the preprocessor.
24+
*/
25+
export function trackComponent(options?: TrackComponentOptions): void {
26+
const mergedOptions = { ...defaultTrackComponentOptions, ...options };
27+
28+
const transaction = getActiveTransaction();
29+
if (!transaction) {
30+
return;
31+
}
32+
33+
const customComponentName = mergedOptions.componentName;
34+
35+
// current_component.ctor.name is likely to give us the component's name automatically
36+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
37+
const componentName = `<${customComponentName || current_component.constructor.name || DEFAULT_COMPONENT_NAME}>`;
38+
39+
let initSpan: Span | undefined = undefined;
40+
if (mergedOptions.trackInit) {
41+
initSpan = recordInitSpan(transaction, componentName);
42+
}
43+
44+
if (mergedOptions.trackUpdates) {
45+
recordUpdateSpans(componentName, initSpan);
46+
}
47+
}
48+
49+
function recordInitSpan(transaction: Transaction, componentName: string): Span {
50+
const initSpan = transaction.startChild({
51+
op: UI_SVELTE_INIT,
52+
description: componentName,
53+
});
54+
55+
onMount(() => {
56+
initSpan.finish();
57+
});
58+
59+
return initSpan;
60+
}
61+
62+
function recordUpdateSpans(componentName: string, initSpan?: Span): void {
63+
let updateSpan: Span | undefined;
64+
beforeUpdate(() => {
65+
// We need to get the active transaction again because the initial one could
66+
// already be finished or there is currently no transaction going on.
67+
const transaction = getActiveTransaction();
68+
if (!transaction) {
69+
return;
70+
}
71+
72+
// If we are initializing the component when the update span is started, we start it as child
73+
// of the init span. Else, we start it as a child of the transaction.
74+
const parentSpan =
75+
initSpan && !initSpan.endTimestamp && initSpan.transaction === transaction ? initSpan : transaction;
76+
77+
updateSpan = parentSpan.startChild({
78+
op: UI_SVELTE_UPDATE,
79+
description: componentName,
80+
});
81+
});
82+
83+
afterUpdate(() => {
84+
if (!updateSpan) {
85+
return;
86+
}
87+
updateSpan.finish();
88+
updateSpan = undefined;
89+
});
90+
}
91+
92+
function getActiveTransaction(): Transaction | undefined {
93+
const currentHub = getCurrentHub();
94+
const scope = currentHub && currentHub.getScope();
95+
return scope && scope.getTransaction();
96+
}

packages/svelte/src/preprocessors.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import MagicString from 'magic-string';
2+
3+
import { ComponentTrackingInitOptions, PreprocessorGroup, TrackComponentOptions } from './types';
4+
5+
export const defaultComponentTrackingOptions: Required<ComponentTrackingInitOptions> = {
6+
trackComponents: true,
7+
trackInit: true,
8+
trackUpdates: true,
9+
};
10+
11+
/**
12+
* Svelte Preprocessor to inject Sentry performance monitoring related code
13+
* into Svelte components.
14+
*/
15+
export function componentTrackingPreprocessor(options?: ComponentTrackingInitOptions): PreprocessorGroup {
16+
const mergedOptions = { ...defaultComponentTrackingOptions, ...options };
17+
18+
const visitedFiles = new Set<string>();
19+
20+
return {
21+
// This script hook is called whenever a Svelte component's <script>
22+
// content is preprocessed.
23+
// `content` contains the script code as a string
24+
script: ({ content, filename, attributes }) => {
25+
// TODO: Not sure when a filename could be undefined. Using this 'unknown' fallback for the time being
26+
const finalFilename = filename || 'unknown';
27+
28+
if (!shouldInjectFunction(mergedOptions.trackComponents, finalFilename, attributes, visitedFiles)) {
29+
return { code: content };
30+
}
31+
32+
const { trackInit, trackUpdates } = mergedOptions;
33+
const trackComponentOptions: TrackComponentOptions = {
34+
trackInit,
35+
trackUpdates,
36+
componentName: getBaseName(finalFilename),
37+
};
38+
39+
const importStmt = 'import { trackComponent } from "@sentry/svelte";\n';
40+
const functionCall = `trackComponent(${JSON.stringify(trackComponentOptions)});\n`;
41+
42+
const s = new MagicString(content);
43+
s.prepend(functionCall).prepend(importStmt);
44+
45+
const updatedCode = s.toString();
46+
const updatedSourceMap = s.generateMap().toString();
47+
48+
return { code: updatedCode, map: updatedSourceMap };
49+
},
50+
};
51+
}
52+
53+
function shouldInjectFunction(
54+
trackComponents: Required<ComponentTrackingInitOptions['trackComponents']>,
55+
filename: string,
56+
attributes: Record<string, string | boolean>,
57+
visitedFiles: Set<string>,
58+
): boolean {
59+
// We do cannot inject our function multiple times into the same component
60+
// This can happen when a component has multiple <script> blocks
61+
if (visitedFiles.has(filename)) {
62+
return false;
63+
}
64+
visitedFiles.add(filename);
65+
66+
// We can't inject our function call into <script context="module"> blocks
67+
// because the code inside is not executed when the component is instantiated but
68+
// when the module is first imported.
69+
// see: https://svelte.dev/docs#component-format-script-context-module
70+
if (attributes.context === 'module') {
71+
return false;
72+
}
73+
74+
if (!trackComponents) {
75+
return false;
76+
}
77+
78+
if (Array.isArray(trackComponents)) {
79+
const componentName = getBaseName(filename);
80+
return trackComponents.some(allowed => allowed === componentName);
81+
}
82+
83+
return true;
84+
}
85+
86+
function getBaseName(filename: string): string {
87+
const segments = filename.split('/');
88+
return segments[segments.length - 1].replace('.svelte', '');
89+
}

packages/svelte/src/types.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// The following types were copied from 'svelte/compiler'-internal
2+
// type definitions
3+
// see: https://github.com/sveltejs/svelte/blob/master/src/compiler/preprocess/types.ts
4+
interface Processed {
5+
code: string;
6+
map?: string | Record<string, unknown>;
7+
dependencies?: string[];
8+
toString?: () => string;
9+
}
10+
11+
type MarkupPreprocessor = (options: {
12+
content: string;
13+
filename?: string;
14+
}) => Processed | void | Promise<Processed | void>;
15+
16+
type Preprocessor = (options: {
17+
/**
18+
* The script/style tag content
19+
*/
20+
content: string;
21+
attributes: Record<string, string | boolean>;
22+
/**
23+
* The whole Svelte file content
24+
*/
25+
markup: string;
26+
filename?: string;
27+
}) => Processed | void | Promise<Processed | void>;
28+
29+
export interface PreprocessorGroup {
30+
markup?: MarkupPreprocessor;
31+
style?: Preprocessor;
32+
script?: Preprocessor;
33+
}
34+
35+
// Alternatively, we could use a direct from svelte/compiler/preprocess
36+
// TODO: figure out what's better and roll with that
37+
// import { PreprocessorGroup } from 'svelte/types/compiler/preprocess';
38+
39+
export type SpanOptions = {
40+
/**
41+
* If true, a span is recorded between a component's intialization and its
42+
* onMount lifecycle hook. This span tells how long it takes a component
43+
* to be created and inserted into the DOM.
44+
*
45+
* Defaults to true if component tracking is enabled
46+
*/
47+
trackInit?: boolean;
48+
49+
/**
50+
* If true, a span is recorded between a component's beforeUpdate and afterUpdate
51+
* lifecycle hooks.
52+
*
53+
* Defaults to true if component tracking is enabled
54+
*/
55+
trackUpdates?: boolean;
56+
};
57+
58+
/**
59+
* Control which components and which operations should be tracked
60+
* and recorded as spans
61+
*/
62+
export type ComponentTrackingInitOptions = {
63+
/**
64+
* Control if all your Svelte components should be tracked or only a specified list
65+
* of components.
66+
* If set to true, all components will be tracked.
67+
* If you only want to track a selection of components, specify the component names
68+
* as an array.
69+
*
70+
* Defaults to true if the preprocessor is used
71+
*/
72+
trackComponents?: boolean | string[];
73+
} & SpanOptions;
74+
75+
export type TrackComponentOptions = {
76+
componentName?: string;
77+
} & SpanOptions;

0 commit comments

Comments
 (0)