Skip to content

feat(svelte): Add Component Tracking #5612

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Aug 31, 2022
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/svelte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@
"@sentry/browser": "7.11.1",
"@sentry/types": "7.11.1",
"@sentry/utils": "7.11.1",
"magic-string": "^0.26.2",
"tslib": "^1.9.3"
},
"peerDependencies": {
"svelte": "3.x"
},
"devDependencies": {
"svelte": "3.49.0"
},
"scripts": {
"build": "run-p build:rollup build:types",
"build:dev": "run-s build",
Expand Down
7 changes: 6 additions & 1 deletion packages/svelte/rollup.npm.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js';

export default makeNPMConfigVariants(makeBaseNPMConfig());
export default makeNPMConfigVariants(
makeBaseNPMConfig({
// Prevent 'svelte/internal' stuff from being included in the built JS
packageSpecificConfig: { external: ['svelte/internal'] },
}),
);
5 changes: 5 additions & 0 deletions packages/svelte/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const UI_SVELTE_INIT = 'ui.svelte.init';

export const UI_SVELTE_UPDATE = 'ui.svelte.update';

export const DEFAULT_COMPONENT_NAME = 'Svelte Component';
8 changes: 8 additions & 0 deletions packages/svelte/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
export type {
ComponentTrackingInitOptions as ComponentTrackingOptions,
TrackComponentOptions as TrackingOptions,
} from './types';

export * from '@sentry/browser';

export { init } from './sdk';

export { componentTrackingPreprocessor } from './preprocessors';
export { trackComponent } from './performance';
93 changes: 93 additions & 0 deletions packages/svelte/src/performance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { getCurrentHub } from '@sentry/browser';
import { Span, Transaction } from '@sentry/types';
import { afterUpdate, beforeUpdate, onMount } from 'svelte';
import { current_component } from 'svelte/internal';

import { DEFAULT_COMPONENT_NAME, UI_SVELTE_INIT, UI_SVELTE_UPDATE } from './constants';
import { TrackComponentOptions } from './types';

const defaultOptions: Required<Pick<TrackComponentOptions, 'trackInit' | 'trackUpdates'>> &
Pick<TrackComponentOptions, 'componentName'> = {
trackInit: true,
trackUpdates: true,
};

/**
* Tracks the Svelte component's intialization and mounting operation as well as
* updates and records them as spans.
* This function is injected automatically into your Svelte components' code
* if you are using the Sentry componentTrackingPreprocessor.
* Alternatively, you can call it yourself if you don't want to use the preprocessor.
*/
export function trackComponent(options?: TrackComponentOptions): void {
const mergedOptions = { ...defaultOptions, ...options };

const transaction = getActiveTransaction();
if (!transaction) {
return;
}

const customComponentName = mergedOptions.componentName;

// current_component.ctor.name is likely to give us the component's name automatically
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const componentName = `<${customComponentName || current_component.constructor.name || DEFAULT_COMPONENT_NAME}>`;

let initSpan: Span | undefined = undefined;
if (mergedOptions.trackInit) {
initSpan = recordInitSpan(transaction, componentName);
}

if (mergedOptions.trackUpdates) {
recordUpdateSpans(componentName, initSpan);
}
}

function recordInitSpan(transaction: Transaction, componentName: string): Span {
const initSpan = transaction.startChild({
op: UI_SVELTE_INIT,
description: componentName,
});

onMount(() => {
initSpan.finish();
});

return initSpan;
}

function recordUpdateSpans(componentName: string, initSpan?: Span): void {
let updateSpan: Span | undefined;
beforeUpdate(() => {
// We need to get the active transaction again because the initial one could
// already be finished or there is currently no transaction going on.
const transaction = getActiveTransaction();
if (!transaction) {
return;
}

// If we are initializing the component when the update span is started, we start it as child
// of the init span. Else, we start it as a child of the transaction.
const parentSpan =
initSpan && !initSpan.endTimestamp && initSpan.transaction === transaction ? initSpan : transaction;

updateSpan = parentSpan.startChild({
op: UI_SVELTE_UPDATE,
description: componentName,
});
});

afterUpdate(() => {
if (!updateSpan) {
return;
}
updateSpan.finish();
updateSpan = undefined;
});
}

function getActiveTransaction(): Transaction | undefined {
const currentHub = getCurrentHub();
const scope = currentHub && currentHub.getScope();
return scope && scope.getTransaction();
}
90 changes: 90 additions & 0 deletions packages/svelte/src/preprocessors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import MagicString from 'magic-string';

import { ComponentTrackingInitOptions, PreprocessorGroup, TrackComponentOptions } from './types';

export const defaultComponentTrackingOptions: Required<ComponentTrackingInitOptions> = {
trackComponents: true,
trackInit: true,
trackUpdates: true,
};

/**
* Svelte Preprocessor to inject Sentry performance monitoring related code
* into Svelte components.
*/
export function componentTrackingPreprocessor(options?: ComponentTrackingInitOptions): PreprocessorGroup {
const mergedOptions = { ...defaultComponentTrackingOptions, ...options };

const visitedFiles = new Set<string>();

return {
// This script hook is called whenever a Svelte component's <script>
// content is preprocessed.
// `content` contains the script code as a string
script: ({ content, filename, attributes }) => {
// TODO: Not sure when a filename could be undefined. Using this 'unknown' fallback for the time being
const finalFilename = filename || 'unknown';

if (!shouldInjectFunction(mergedOptions.trackComponents, finalFilename, attributes, visitedFiles)) {
return { code: content };
}

const { trackInit, trackUpdates } = mergedOptions;
const trackComponentOptions: TrackComponentOptions = {
trackInit,
trackUpdates,
componentName: getBaseName(finalFilename),
};

const importStmt = 'import { trackComponent } from "@sentry/svelte";\n';
const functionCall = `trackComponent(${JSON.stringify(trackComponentOptions)});\n`;

const s = new MagicString(content);
s.prepend(functionCall).prepend(importStmt);

const updatedCode = s.toString();
const updatedSourceMap = s.generateMap().toString();

return { code: updatedCode, map: updatedSourceMap };
},
};
}

function shouldInjectFunction(
trackComponents: Required<ComponentTrackingInitOptions['trackComponents']>,
filename: string,
attributes: Record<string, string | boolean>,
visitedFiles: Set<string>,
): boolean {
// We do cannot inject our function multiple times into the same component
// This can happen when a component has multiple <script> blocks
if (visitedFiles.has(filename)) {
return false;
}
visitedFiles.add(filename);

// We can't inject our function call into <script context="module"> blocks
// because the code inside is not executed when the component is instantiated but
// when the module is first imported.
// see: https://svelte.dev/docs#component-format-script-context-module
if (attributes.context === 'module') {
return false;
}

if (!trackComponents) {
return false;
}

if (Array.isArray(trackComponents)) {
const componentName = getBaseName(filename);
return trackComponents.some(allowed => allowed === componentName);
}

return true;
}

function getBaseName(filename: string): string {
const segments = filename.split('/');
const componentName = segments[segments.length - 1].replace('.svelte', '');
return componentName;
}
77 changes: 77 additions & 0 deletions packages/svelte/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// The following types were copied from 'svelte/compiler'-internal
// type definitions
// see: https://github.com/sveltejs/svelte/blob/master/src/compiler/preprocess/types.ts
interface Processed {
code: string;
map?: string | Record<string, unknown>;
dependencies?: string[];
toString?: () => string;
}

type MarkupPreprocessor = (options: {
content: string;
filename?: string;
}) => Processed | void | Promise<Processed | void>;

type Preprocessor = (options: {
/**
* The script/style tag content
*/
content: string;
attributes: Record<string, string | boolean>;
/**
* The whole Svelte file content
*/
markup: string;
filename?: string;
}) => Processed | void | Promise<Processed | void>;

export interface PreprocessorGroup {
markup?: MarkupPreprocessor;
style?: Preprocessor;
script?: Preprocessor;
}

// Alternatively, we could use a direct from svelte/compiler/preprocess
// TODO: figure out what's better and roll with that
// import { PreprocessorGroup } from 'svelte/types/compiler/preprocess';

export type SpanOptions = {
/**
* If true, a span is recorded between a component's intialization and its
* onMount lifecycle hook. This span tells how long it takes a component
* to be created and inserted into the DOM.
*
* Defaults to true if component tracking is enabled
*/
trackInit?: boolean;

/**
* If true, a span is recorded between a component's beforeUpdate and afterUpdate
* lifecycle hooks.
*
* Defaults to true if component tracking is enabled
*/
trackUpdates?: boolean;
};

/**
* Control which components and which operations should be tracked
* and recorded as spans
*/
export type ComponentTrackingInitOptions = {
/**
* Control if all your Svelte components should be tracked or only a specified list
* of components.
* If set to true, all components will be tracked.
* If you only want to track a selection of components, specify the component names
* as an array.
*
* Defaults to true if the preprocessor is used
*/
trackComponents?: boolean | string[];
} & SpanOptions;

export type TrackComponentOptions = {
componentName?: string;
} & SpanOptions;
Loading