Skip to content
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

feat(angular): Add Sentry setup in App Config #769

Open
wants to merge 3 commits into
base: onur/angular-wizard-base
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
25 changes: 24 additions & 1 deletion src/angular/angular-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import clack from '@clack/prompts';

import chalk from 'chalk';
import type { WizardOptions } from '../utils/types';
import { withTelemetry } from '../telemetry';
import { traceStep, withTelemetry } from '../telemetry';
import {
abortIfCancelled,
confirmContinueIfNoOrDirtyGitRepo,
ensurePackageIsInstalled,
featureSelectionPrompt,
getPackageDotJson,
installPackage,
printWelcome,
Expand All @@ -18,6 +19,7 @@ import { getPackageVersion, hasPackageInstalled } from '../utils/package-json';
import { gte, minVersion, SemVer } from 'semver';

import * as Sentry from '@sentry/node';
import { updateAppConfig } from './sdk-setup';

const MIN_SUPPORTED_ANGULAR_VERSION = '14.0.0';

Expand Down Expand Up @@ -111,4 +113,25 @@ ${chalk.underline(
packageNameDisplayLabel: '@sentry/angular',
alreadyInstalled: sdkAlreadyInstalled,
});

const selectedFeatures = await featureSelectionPrompt([
{
id: 'performance',
prompt: `Do you want to enable ${chalk.bold(
'Tracing',
)} to track the performance of your application?`,
enabledHint: 'recommended',
},
{
id: 'replay',
prompt: `Do you want to enable ${chalk.bold(
'Sentry Session Replay',
)} to get a video-like reproduction of errors during a user session?`,
enabledHint: 'recommended, but increases bundle size',
},
] as const);

await traceStep('Update Angular project configuration', async () => {
await updateAppConfig(installedMinVersion, selectedFeatures.performance);
});
}
265 changes: 265 additions & 0 deletions src/angular/codemods/app-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */

import type { ArrayExpression, Identifier, ObjectProperty } from '@babel/types';

// @ts-expect-error - magicast is ESM and TS complains about that. It works though
import type { ProxifiedModule } from 'magicast';

// @ts-ignore - clack is ESM and TS complains about that. It works though
import * as clack from '@clack/prompts';
import { gte, type SemVer } from 'semver';
import * as recast from 'recast';
import chalk from 'chalk';

export function updateAppConfigMod(
originalAppConfigMod: ProxifiedModule<any>,
angularVersion: SemVer,
isTracingEnabled: boolean,
): ProxifiedModule<any> {
const isAboveAngularV19 = gte(angularVersion, '19.0.0');

addImports(originalAppConfigMod, isAboveAngularV19, isTracingEnabled);
addProviders(originalAppConfigMod, isAboveAngularV19, isTracingEnabled);

return originalAppConfigMod;
}

function addSentryImport(originalAppConfigMod: ProxifiedModule<any>): void {
const imports = originalAppConfigMod.imports;
const hasSentryImport = imports.$items.some(
(item) => item.from === '@sentry/angular',
);

if (!hasSentryImport) {
imports.$add({
from: '@sentry/angular',
imported: '*',
local: 'Sentry',
});
}
}

function addErrorHandlerImport(
originalAppConfigMod: ProxifiedModule<any>,
): void {
const imports = originalAppConfigMod.imports;
const hasErrorHandler = imports.$items.some(
(item) => item.local === 'ErrorHandler' && item.from === '@angular/core',
);

if (!hasErrorHandler) {
imports.$add({
from: '@angular/core',
imported: 'ErrorHandler',
local: 'ErrorHandler',
});
}
}

function addRouterImport(originalAppConfigMod: ProxifiedModule<any>): void {
const imports = originalAppConfigMod.imports;
const hasRouter = imports.$items.some(
(item) => item.local === 'Router' && item.from === '@angular/router',
);

if (!hasRouter) {
imports.$add({
from: '@angular/router',
imported: 'Router',
local: 'Router',
});
}
}

function addMissingImportsV19(
originalAppConfigMod: ProxifiedModule<any>,
): void {
const imports = originalAppConfigMod.imports;

const hasProvideAppInitializer = imports.$items.some(
(item) =>
item.local === 'provideAppInitializer' && item.from === '@angular/core',
);

if (!hasProvideAppInitializer) {
imports.$add({
from: '@angular/core',
imported: 'provideAppInitializer',
local: 'provideAppInitializer',
});
}

const hasInject = imports.$items.some(
(item) => item.local === 'inject' && item.from === '@angular/core',
);

if (!hasInject) {
imports.$add({
from: '@angular/core',
imported: 'inject',
local: 'inject',
});
}
}

function addAppInitializer(originalAppConfigMod: ProxifiedModule<any>): void {
const imports = originalAppConfigMod.imports;

const hasAppInitializer = imports.$items.some(
(item) => item.local === 'APP_INITIALIZER' && item.from === '@angular/core',
);

if (!hasAppInitializer) {
imports.$add({
from: '@angular/core',
imported: 'APP_INITIALIZER',
local: 'APP_INITIALIZER',
});
}
}

function addImports(
originalAppConfigMod: ProxifiedModule<any>,
isAboveAngularV19: boolean,
isTracingEnabled: boolean,
): void {
addSentryImport(originalAppConfigMod);
addErrorHandlerImport(originalAppConfigMod);

if (isTracingEnabled) {
addRouterImport(originalAppConfigMod);
}

if (isAboveAngularV19) {
addMissingImportsV19(originalAppConfigMod);
} else if (isTracingEnabled) {
addAppInitializer(originalAppConfigMod);
}
}

function addProviders(
originalAppConfigMod: ProxifiedModule<any>,
isAboveAngularV19: boolean,
isTracingEnabled: boolean,
): void {
const b = recast.types.builders;

recast.visit(originalAppConfigMod.exports.$ast, {
visitExportNamedDeclaration(path) {
// @ts-expect-error - declaration should always be present in this case
if (path.node.declaration.declarations[0].id.name === 'appConfig') {
const appConfigProps =
// @ts-expect-error - declaration should always be present in this case
path.node.declaration.declarations[0].init.properties;

const providers = appConfigProps.find(
(prop: ObjectProperty) =>
(prop.key as Identifier).name === 'providers',
).value as ArrayExpression;

// Check if there is already an ErrorHandler provider
const hasErrorHandlerProvider = providers.elements.some(
(element) =>
element &&
element.type === 'ObjectExpression' &&
element.properties.some(
(prop) =>
prop.type === 'ObjectProperty' &&
(prop.key as Identifier).name === 'provide' &&
(prop.value as Identifier).name === 'ErrorHandler',
),
);

// If there is already an ErrorHandler provider, we skip adding it and log a message
if (hasErrorHandlerProvider) {
clack.log
.warn(`ErrorHandler provider already exists in your app config.
Please refer to the Sentry Angular SDK documentation to combine it manually with Sentry's ErrorHandler.
${chalk.underline(
'https://docs.sentry.io/platforms/javascript/guides/angular/features/error-handler/',
)}
`);
} else {
const errorHandlerObject = b.objectExpression([
b.objectProperty(
b.identifier('provide'),
b.identifier('ErrorHandler'),
),
b.objectProperty(
b.identifier('useValue'),
b.identifier('Sentry.createErrorHandler()'),
),
]);

providers.elements.push(
// @ts-expect-error - errorHandlerObject is an objectExpression
errorHandlerObject,
);
}

if (isTracingEnabled) {
const traceServiceObject = b.objectExpression([
b.objectProperty(
b.identifier('provide'),
b.identifier('Sentry.TraceService'),
),
b.objectProperty(
b.identifier('deps'),
b.arrayExpression([b.identifier('Router')]),
),
]);

// @ts-expect-error - traceServiceObject is an objectExpression
providers.elements.push(traceServiceObject);

if (isAboveAngularV19) {
const provideAppInitializerCall = b.callExpression(
b.identifier('provideAppInitializer'),
[
b.arrowFunctionExpression(
[],
b.blockStatement([
b.expressionStatement(
b.callExpression(b.identifier('inject'), [
b.identifier('Sentry.TraceService'),
]),
),
]),
),
],
);

// @ts-expect-error - provideAppInitializerCall is an objectExpression
providers.elements.push(provideAppInitializerCall);
} else {
const provideAppInitializerObject = b.objectExpression([
b.objectProperty(
b.identifier('provide'),
b.identifier('APP_INITIALIZER'),
),
b.objectProperty(
b.identifier('useFactory'),
b.arrowFunctionExpression(
[],
b.arrowFunctionExpression([], b.blockStatement([])),
),
),
b.objectProperty(
b.identifier('deps'),
b.arrayExpression([b.identifier('Sentry.TraceService')]),
),
b.objectProperty(b.identifier('multi'), b.booleanLiteral(true)),
]);

// @ts-expect-error - provideAppInitializerObject is an objectExpression
providers.elements.push(provideAppInitializerObject);
}
}
}

this.traverse(path);
},
});
}
Loading
Loading