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 4 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'] },
}),
);
8 changes: 8 additions & 0 deletions packages/svelte/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// TODO: we might want to call this ui.svelte.init instead because
// it doesn't only track mounting time (there's no before-/afterMount)
// but component init to mount time.
export const UI_SVELTE_MOUNT = 'ui.svelte.mount';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am always in favor of calling things the way users are familiar with. If "initializing" is a term that is used in the svelte community for mounting components, I vote we call it ui.svelte.init

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My train of thought for maybe renaming this to init instead of mount is that this span does not only track mounting time. Because the onMount hook is the only lifecycle hook we have for mounting, we only know when mounting is completed but not when it is started. What we do know, however, is when we start initializing the component (i.e. when the <script> block of a component is executed). So this span tracks exactly this duration: From the beginning of script execution until the component is completely mounted in the DOM. Which technically makes this more an initialization span than a mounting span.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed it to init. Makes more sense IMO. Let's go with that


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_MOUNT, UI_SVELTE_UPDATE } from './constants';
import { TrackComponentOptions } from './types';

const defaultOptions: Required<Pick<TrackComponentOptions, 'trackMount' | 'trackUpdates'>> &
Pick<TrackComponentOptions, 'componentName'> = {
trackMount: 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 mountSpan: Span | undefined = undefined;
if (mergedOptions.trackMount) {
mountSpan = recordMountSpan(transaction, componentName);
}

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

function recordMountSpan(transaction: Transaction, componentName: string): Span {
const mountSpan = transaction.startChild({
op: UI_SVELTE_MOUNT,
description: componentName,
});

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

return mountSpan;
}

function recordUpdateSpans(componentName: string, mountSpan?: 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 mounting the component when the update span is started, we start it as child
// of the mount span. Else, we start it as a child of the transaction.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you elaborate a bit why the update span is a child of the component's mount span? Without having thought about it too much it doesn't click for me but maybe there's a reason.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, tbh this is totally up for discussion but the reason why I think it makes sense to make the update a child span of the mount span is because in the component lifecycle of Svelte, the beforeUpdate hook will be called before the mounting of the component is finished. Meaning, the first update of a component is part of its initial initialization. So overall, I think it makes sense to let this update (and only this one) be a child of the mount span.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok. The way you explained this makes a lot of sense. I'd keep it too then!

const parentSpan =
mountSpan && !mountSpan.endTimestamp && mountSpan.transaction === transaction ? mountSpan : 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();
}
66 changes: 66 additions & 0 deletions packages/svelte/src/preprocessors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import MagicString from 'magic-string';

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

const defaultComponentTrackingOptions: Required<ComponentTrackingInitOptions> = {
trackComponents: true,
trackMount: 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 };

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 }) => {
if (!shouldInjectFunction(mergedOptions.trackComponents, filename)) {
return { code: content };
}

const { trackMount, trackUpdates } = mergedOptions;
const trackComponentOptions: TrackComponentOptions = {
trackMount,
trackUpdates,
componentName: getComponentName(filename || ''),
};

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 | undefined,
): boolean {
if (!trackComponents || !filename) {
return false;
}
if (Array.isArray(trackComponents)) {
// TODO: this probably needs to be a little more robust
const componentName = getComponentName(filename);
return trackComponents.some(allowed => allowed === componentName);
}
return true;
}
function getComponentName(filename: string): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a node api function that does exactly what this function is doing: path.basename(filename, '.svelte'). No need to change this though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well well well, the more you know 😅

Copy link
Member Author

@Lms24 Lms24 Aug 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I tried replacing my function with path but my Svelte app won't build because I'm using a Node API. There's probably a way to get around this but for now, I'll leave it as is. We can revisit this, if we ever get problems with that function

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO we can just rename getComponentName to reflect what it's doing (getting the basename) and not worry about using the path API.

const segments = filename.split('/');
const componentName = segments[segments.length - 1].replace('.svelte', '');
return componentName;
}
76 changes: 76 additions & 0 deletions packages/svelte/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// 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, spans are be recorded between component intialization and its
* onMount lifecycle hook.
*
* Defaults to true if component tracking is enabled
*/
trackMount?: boolean;

/**
* If true, spans are recorded between each 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;
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -24837,6 +24837,11 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==

[email protected]:
version "3.49.0"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.49.0.tgz#5baee3c672306de1070c3b7888fc2204e36a4029"
integrity sha512-+lmjic1pApJWDfPCpUUTc1m8azDqYCG1JN9YEngrx/hUyIcFJo6VZhj0A1Ai0wqoHcEIuQy+e9tk+4uDgdtsFA==

svgo@^1.0.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
Expand Down