diff --git a/packages/pylon-cli/package.json b/packages/pylon-cli/package.json index 29db2b5..cf0344c 100644 --- a/packages/pylon-cli/package.json +++ b/packages/pylon-cli/package.json @@ -21,6 +21,7 @@ "dependencies": { "@gqty/cli": "4.1.0-canary-20240708145747.43f0b4262b24cc7de046a0d5b04777bd9ac1eb21", "@inquirer/prompts": "^5.1.2", + "@sentry/bun": "^8.17.0", "commander": "^10.0.0" }, "peerDependencies": { diff --git a/packages/pylon-cli/src/commands/develop.ts b/packages/pylon-cli/src/commands/develop.ts index 0e78306..d9d79eb 100644 --- a/packages/pylon-cli/src/commands/develop.ts +++ b/packages/pylon-cli/src/commands/develop.ts @@ -1,12 +1,12 @@ import {build} from '@getcronit/pylon-builder' -import {$, Subprocess} from 'bun' +import {$, Subprocess, spawnSync} from 'bun' -import {sfiBuildPath, sfiSourcePath} from '../constants.js' -import path from 'path' import {fetchSchema, generateClient} from '@gqty/cli' +import path from 'path' +import {sfiBuildPath, sfiSourcePath} from '../constants.js' export default async (options: {port: string}) => { - const {stdout} = await $`npx -p which which pylon-server` + const {stdout} = spawnSync(['npx', '-p', 'which', 'which', 'pylon-server']) const binPath = stdout.toString().trim() @@ -21,7 +21,11 @@ export default async (options: {port: string}) => { currentProc = Bun.spawn({ cmd: ['bun', 'run', binPath, '--port', options.port], - stdout: 'inherit' + stdout: 'inherit', + env: { + ...process.env, + NODE_ENV: 'development' + } }) } @@ -32,8 +36,6 @@ export default async (options: {port: string}) => { if (clientPath) { const endpoint = `http://localhost:${options.port}/graphql` - console.log('Generating client...', {endpoint, clientPath}) - const schema = await fetchSchema(endpoint) await generateClient(schema, { diff --git a/packages/pylon-cli/src/commands/new.ts b/packages/pylon-cli/src/commands/new.ts index e1483f0..fd46ebf 100644 --- a/packages/pylon-cli/src/commands/new.ts +++ b/packages/pylon-cli/src/commands/new.ts @@ -1,8 +1,12 @@ -import {execSync} from 'child_process' -import {existsSync, mkdirSync} from 'fs' import {join} from 'path' import {setPackageName} from '../utils/set-package-name' +import {getLogger} from '@getcronit/pylon' +import {access} from 'fs/promises' +import {mkdir} from 'fs/promises' +import {constants} from 'fs/promises' + +const logger = getLogger() export default async ( rootPath: string, @@ -12,56 +16,90 @@ export default async ( clientPath?: string } ) => { + await new Promise(resolve => setTimeout(resolve, 100)) const name = options.name - console.log( - `Creating project ${name} at ${rootPath} from template ${template}` - ) + logger.info(`🚀 Starting project creation: ${name}`) + logger.info(`📁 Destination: ${rootPath}`) + logger.info(`🔖 Template: ${template}`) + + await new Promise(resolve => setTimeout(resolve, 100)) - // Check if the project directory already exists - const projectDir = join(process.cwd(), rootPath) - if (existsSync(projectDir)) { - console.error( - `Error: Project directory "${name}" already exists in "${rootPath}".` + // await new Promise(resolve => setTimeout(resolve, 100)) + + if (options.clientPath) { + logger.info( + `🔧 Client path will be inserted into package.json: ${options.clientPath}` ) - process.exit(1) - } else { - mkdirSync(rootPath, {recursive: true}) } - // Clone the template repository into the project directory - execSync(`git clone ${template} "${projectDir}"`) + try { + const projectDir = join(process.cwd(), rootPath) - // Remove the .git directory from the project directory - execSync(`rm -rf "${join(projectDir, '.git')}"`) + try { + await access(projectDir, constants.F_OK) - // Set the project name in the package.json file - setPackageName(projectDir, name) + throw new Error( + `Project directory "${name}" already exists in "${rootPath}".` + ) + } catch (err: any) { + if (err.code !== 'ENOENT') { + throw err // Re-throw unexpected errors + } + // Directory does not exist, continue + } - // Initialize a new git repository in the project directory - execSync(`git init "${projectDir}"`) + await mkdir(rootPath, {recursive: true}) - // Add all files to the git repository - execSync(`git -C "${projectDir}" add .`) + logger.info(`Created directory: ${rootPath}`) - // Create an initial commit - execSync(`git -C "${projectDir}" commit -m "Initial commit"`) + // Clone the template repository into the project directory + logger.info(`Cloning template from ${template} into ${projectDir}`) + await Bun.$`git clone ${template} "${projectDir}"` - console.log('Installing project dependencies...') + // Remove the .git directory from the project directory + logger.info('Removing existing .git directory') + await Bun.$`rm -rf "${join(projectDir, '.git')}"` - // Bun install the project dependencies - execSync(`cd "${projectDir}" && bun install`, { - stdio: 'inherit' - }) + // Set the project name in the package.json file + logger.info('Setting project name in package.json') + setPackageName(projectDir, name) - // Insert the client path into the package.json file (gqty key) - if (options.clientPath) { - execSync( - `cd "${projectDir}" && npx json -q -I -f package.json -e 'this.pylon = this.pylon || {}; this.pylon.gqty="${options.clientPath}"'` - ) + // Initialize a new git repository in the project directory + logger.info('Initializing new git repository') + await Bun.$`git init "${projectDir}"` - console.log('Inserted client path into package.json') - } + // Add all files to the git repository + logger.info('Adding files to git repository') + await Bun.$`git -C "${projectDir}" add .` + + // Create an initial commit + logger.info('Creating initial commit') + await Bun.$`git -C "${projectDir}" commit -m "Initial commit"` - console.log(`Project ${name} created successfully at ${projectDir}.`) + logger.info('Installing project dependencies...') + // Bun install the project dependencies + await Bun.$`cd "${projectDir}" && bun install` + + // Insert the client path into the package.json file (gqty key) + if (options.clientPath) { + logger.info('Inserting client path into package.json') + await Bun.$`cd "${projectDir}" && npx json -q -I -f package.json -e 'this.pylon = this.pylon || {}; this.pylon.gqty="${options.clientPath}"'` + + logger.info('Inserted client path into package.json') + + // Add @gqty/react and gqty to the cwd package.json and prompt the user to install them + await Bun.$`npx json -q -I -f package.json -e 'this.devDependencies = this.devDependencies || {}; this.devDependencies["@gqty/react"] = "*"'` + + await Bun.$`npx json -q -I -f package.json -e 'this.devDependencies = this.devDependencies || {}; this.devDependencies["gqty"] = "*"'` + + logger.info( + 'Added @gqty/react and gqty to package.json. Run "install" to install them' + ) + } + + logger.info(`🎉 Project ${name} created successfully at ${projectDir}.`) + } catch (err: any) { + throw new Error(`Error creating project: ${err.message}`) + } } diff --git a/packages/pylon-cli/src/index.ts b/packages/pylon-cli/src/index.ts index 8a81a7a..e328a3d 100644 --- a/packages/pylon-cli/src/index.ts +++ b/packages/pylon-cli/src/index.ts @@ -5,6 +5,8 @@ import {input, select, confirm} from '@inquirer/prompts' import packageJson from './utils/package-json.js' import * as commands from './commands/index.js' import {cliName} from './constants.js' +import {logger} from '@getcronit/pylon' +import * as Sentry from '@sentry/bun' // Set development environment process.env.NODE_ENV = process.env.NODE_ENV || 'development' @@ -13,6 +15,13 @@ export const program = new Command() program.name(cliName).description('Pylon CLI').version(packageJson.version) +Sentry.init({ + dsn: 'https://cb8dd08e25022c115327258343ffb657@sentry.cronit.io/9', + environment: process.env.NODE_ENV, + normalizeDepth: 10, + tracesSampleRate: 1 +}) + program .command('develop') .description('Start the development server') @@ -49,13 +58,13 @@ program .action(commands.new) if (!process.argv.slice(2).length) { - ;(async () => { + try { const answer = await select({ message: 'What action would you like to take?', choices: [ - {name: 'Create a new pylon', value: 'new'}, - {name: 'Start development server', value: 'develop'}, - {name: 'Build the pylon', value: 'build'} + {name: 'New', value: 'new'}, + {name: 'Develop', value: 'develop'}, + {name: 'Build', value: 'build'} ] }) @@ -100,7 +109,9 @@ if (!process.argv.slice(2).length) { } else if (answer === 'build') { await commands.build({}) } - })() + } catch (e: any) { + logger.error(e.toString()) + } } else { program.parse() } diff --git a/packages/pylon-server/package.json b/packages/pylon-server/package.json index a1adcc3..23c8eec 100644 --- a/packages/pylon-server/package.json +++ b/packages/pylon-server/package.json @@ -17,8 +17,7 @@ ], "license": "UNLICENSED", "dependencies": { - "@hono/sentry": "^1.0.1", - "@sentry/bun": "^7.106.0", + "@sentry/bun": "^8.17.0", "commander": "^11.0.0", "graphql": "^16.6.0", "graphql-yoga": "^5.3.0", diff --git a/packages/pylon-server/server.js b/packages/pylon-server/server.js index 44dc249..368b0d4 100755 --- a/packages/pylon-server/server.js +++ b/packages/pylon-server/server.js @@ -3,6 +3,7 @@ // Set default env variables process.env.NODE_ENV = process.env.NODE_ENV || 'production' +import {logger} from '@getcronit/pylon' import {makeApp, runtime} from '@getcronit/pylon-server' import {Command} from 'commander' import fs from 'fs' @@ -80,4 +81,4 @@ configureServer?.(server) runtime.server = server -console.log(`Listening on localhost:`, server.port) +logger.info(`Server listening on port ${args.port}`) diff --git a/packages/pylon-server/src/envelope/use-sentry.ts b/packages/pylon-server/src/envelop/use-sentry.ts similarity index 51% rename from packages/pylon-server/src/envelope/use-sentry.ts rename to packages/pylon-server/src/envelop/use-sentry.ts index 3956945..557fb37 100644 --- a/packages/pylon-server/src/envelope/use-sentry.ts +++ b/packages/pylon-server/src/envelop/use-sentry.ts @@ -7,20 +7,27 @@ import { TypedExecutionArgs, type Plugin } from '@envelop/core' -import * as Sentry from '@sentry/bun' -import type {TraceparentData} from '@sentry/types' +import * as Sentry from '@sentry/node' +import type {Span, TraceparentData} from '@sentry/types' export type SentryPluginOptions> = { /** - * Adds result of each resolver and operation to Span's data (available under "result") + * Starts a new transaction for every GraphQL Operation. + * When disabled, an already existing Transaction will be used. + * + * @default true + */ + startTransaction?: boolean + /** + * Renames Transaction. * @default false */ - includeRawResult?: boolean + renameTransaction?: boolean /** - * Adds arguments of each resolver to Span's tag called "args" + * Adds result of each resolver and operation to Span's data (available under "result") * @default false */ - includeResolverArgs?: boolean + includeRawResult?: boolean /** * Adds operation's variables to a Scope (only in case of errors) * @default false @@ -37,6 +44,13 @@ export type SentryPluginOptions> = { appendTags?: ( args: TypedExecutionArgs ) => Record + /** + * Callback to set context information onto the scope. + */ + configureScope?: ( + args: TypedExecutionArgs, + scope: Sentry.Scope + ) => void /** * Produces a name of Transaction (only when "renameTransaction" or "startTransaction" are enabled) and description of created Span. * @@ -69,6 +83,8 @@ export type SentryPluginOptions> = { skipError?: (args: Error) => boolean } +export const defaultSkipError = isOriginalGraphQLError + export const useSentry = = {}>( options: SentryPluginOptions = {} ): Plugin => { @@ -79,9 +95,12 @@ export const useSentry = = {}>( return options[key] ?? defaultValue } + const startTransaction = pick('startTransaction', true) const includeRawResult = pick('includeRawResult', false) const includeExecuteVariables = pick('includeExecuteVariables', false) + const renameTransaction = pick('renameTransaction', false) const skipOperation = pick('skip', () => false) + const skipError = pick('skipError', defaultSkipError) const eventIdKey = options.eventIdKey === null ? null : 'sentryEventId' @@ -123,84 +142,105 @@ export const useSentry = = {}>( ...addedTags } - return Sentry.startSpan({name: transactionName, op, tags}, span => { - span?.setAttribute('document', document) - - return { - onExecuteDone(payload) { - const handleResult: OnExecuteDoneHookResultOnNextHook<{}> = ({ - result, - setResult - }) => { - if (includeRawResult) { - span?.setAttribute('result', JSON.parse(JSON.stringify(result))) - } + if (options.configureScope) { + options.configureScope(args, Sentry.getCurrentScope()) + } - if (result.errors && result.errors.length > 0) { - Sentry.withScope(scope => { - scope.setTransactionName(opName) - scope.setTag('operation', operationType) - scope.setTag('operationName', opName) - scope.setExtra('document', document) - scope.setTags(addedTags || {}) - - if (includeRawResult) { - scope.setExtra('result', result) - } - - if (includeExecuteVariables) { - scope.setExtra('variables', args.variableValues) - } - - const errors = result.errors?.map(err => { - const errorPath = (err.path ?? []) - .map((v: string | number) => - typeof v === 'number' ? '$index' : v - ) - .join(' > ') - - if (errorPath) { - scope.addBreadcrumb({ - category: 'execution-path', - message: errorPath, - level: 'debug' - }) + return { + onExecuteDone(payload) { + const handleResult: OnExecuteDoneHookResultOnNextHook<{}> = ({ + result, + setResult + }) => { + Sentry.startSpanManual( + { + op, + name: opName, + attributes: tags + }, + span => { + if (renameTransaction) { + span.updateName(transactionName) + } + + span.setAttribute('document', document) + + if (includeRawResult) { + span.setAttribute('result', JSON.stringify(result)) + } + + if (result.errors && result.errors.length > 0) { + Sentry.withScope(scope => { + scope.setTransactionName(opName) + scope.setTag('operation', operationType) + scope.setTag('operationName', opName) + scope.setExtra('document', document) + + scope.setTags(addedTags || {}) + + if (includeRawResult) { + scope.setExtra('result', result) } - const eventId = Sentry.captureException(err.originalError, { - fingerprint: [ - 'graphql', - errorPath, - opName, - operationType - ], - level: isOriginalGraphQLError(err) ? 'info' : 'error', - contexts: { - GraphQL: { - operationName: opName, - operationType, - variables: args.variableValues - } + if (includeExecuteVariables) { + scope.setExtra('variables', args.variableValues) + } + + const errors = result.errors?.map(err => { + if (skipError(err) === true) { + return err + } + + const errorPath = (err.path ?? []) + .map((v: string | number) => + typeof v === 'number' ? '$index' : v + ) + .join(' > ') + + if (errorPath) { + scope.addBreadcrumb({ + category: 'execution-path', + message: errorPath, + level: 'debug' + }) } + + const eventId = Sentry.captureException( + err.originalError, + { + fingerprint: [ + 'graphql', + errorPath, + opName, + operationType + ], + contexts: { + GraphQL: { + operationName: opName, + operationType, + variables: args.variableValues + } + } + } + ) + + return addEventId(err, eventId) }) - return addEventId(err, eventId) + setResult({ + ...result, + errors + }) }) + } - setResult({ - ...result, - errors - }) - }) + span.end() } - - span?.end() - } - - return handleStreamOrSingleExecutionResult(payload, handleResult) + ) } + return handleStreamOrSingleExecutionResult(payload, handleResult) } - }) + } } } } diff --git a/packages/pylon-server/src/handler/graphql-handler.ts b/packages/pylon-server/src/handler/graphql-handler.ts index e792181..f6b5eb2 100644 --- a/packages/pylon-server/src/handler/graphql-handler.ts +++ b/packages/pylon-server/src/handler/graphql-handler.ts @@ -3,8 +3,7 @@ import {GraphQLScalarType, Kind} from 'graphql' import {Context} from '@getcronit/pylon' import {BuildSchemaOptions} from '../make-app' - -import {useSentry} from '../envelope/use-sentry' +import {useSentry} from '../envelop/use-sentry' export const graphqlHandler = (c: Context) => @@ -31,8 +30,6 @@ export const graphqlHandler = ) }, serialize(value) { - console.log('serialize', value, typeof value) - if (value instanceof Date) { return value.toISOString() // value sent to the client } @@ -59,11 +56,7 @@ export const graphqlHandler = landingPage: false, plugins: [ - useSentry({ - includeResolverArgs: true, - includeExecuteVariables: true, - includeRawResult: true - }) + useSentry() // useLogger({ // logFn: (eventName, args) => { // // Event could be execute-start / execute-end / subscribe-start / subscribe-end / etc. diff --git a/packages/pylon-server/src/index.ts b/packages/pylon-server/src/index.ts index e9ae6cc..00d5821 100644 --- a/packages/pylon-server/src/index.ts +++ b/packages/pylon-server/src/index.ts @@ -21,3 +21,21 @@ class Runtime { } export const runtime = new Runtime() + +import * as Sentry from '@sentry/bun' + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.NODE_ENV, + integrations: [ + Sentry.bunServerIntegration(), + // Sentry.anrIntegration({captureStackTrace: true}), + Sentry.prismaIntegration(), + Sentry.graphqlIntegration({ + ignoreResolveSpans: false, + ignoreTrivalResolveSpans: false + }) + ], + normalizeDepth: 10, + tracesSampleRate: 1 +}) diff --git a/packages/pylon/package.json b/packages/pylon/package.json index 9cea164..6c0f5e4 100644 --- a/packages/pylon/package.json +++ b/packages/pylon/package.json @@ -18,10 +18,10 @@ "microbundle": "^0.15.1" }, "peerDependencies": { - "hono": "^4.0.8" + "hono": "^4.0.8", + "@sentry/bun": "^8.17.0" }, "dependencies": { - "@sentry/bun": "^7.106.0", "graphql": "^16.9.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", diff --git a/packages/pylon/src/index.ts b/packages/pylon/src/index.ts index ff668ae..a687390 100644 --- a/packages/pylon/src/index.ts +++ b/packages/pylon/src/index.ts @@ -1,14 +1,4 @@ export {defineService, ServiceError, PylonAPI} from './define-pylon.js' -export {logger} from './logger/index.js' +export {logger, getLogger} from './logger/index.js' export * from './auth/index.js' export {Context, Env, asyncContext, getContext, setContext} from './context.js' - -import * as Sentry from '@sentry/bun' - -Sentry.init({ - dsn: process.env.SENTRY_DSN, - environment: process.env.NODE_ENV, - integrations: [...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations()], - normalizeDepth: 10, - tracesSampleRate: 1 -}) diff --git a/packages/pylon/src/logger/index.ts b/packages/pylon/src/logger/index.ts index 71fd0d2..7289109 100644 --- a/packages/pylon/src/logger/index.ts +++ b/packages/pylon/src/logger/index.ts @@ -2,18 +2,48 @@ import winston from 'winston' import WinstonSentryTransport from './winston-sentry-transport' -export const logger = winston.createLogger({ +const mainLogger = winston.createLogger({ transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss' + }), + winston.format.printf(({timestamp, level, message, label}) => { + if (label) { + return `${timestamp} [${label}] ${level}: ${message}` + } + + // Workaround for the root logger + return `${timestamp} ${level}: ${message}` + }) + ) + }), new WinstonSentryTransport({ - level: 'info' + level: 'info', + skipSentryInit: true }) ] }) -if (process.env.NODE_ENV !== 'production') { - logger.add( - new winston.transports.Console({ - format: winston.format.simple() - }) - ) +/** + * @deprecated Use `getLogger` instead + * + * import {getLogger} from '@getcronit/pylon' + * + * const logger = getLogger(__filename) + */ +export const logger = mainLogger + +// Define the getLogger function +export const getLogger = (moduleName?: string): winston.Logger => { + const childLogger = mainLogger.child({label: moduleName}) + + // Override the child logger's child method to extend the label + childLogger.child = (options: {label: string}) => { + return getLogger(`${moduleName} -> ${options.label}`) + } + + return childLogger } diff --git a/packages/pylon/src/logger/winston-sentry-transport.ts b/packages/pylon/src/logger/winston-sentry-transport.ts index 2786364..e4aae24 100644 --- a/packages/pylon/src/logger/winston-sentry-transport.ts +++ b/packages/pylon/src/logger/winston-sentry-transport.ts @@ -1,141 +1,171 @@ -import _ from 'lodash' +import * as Sentry from '@sentry/bun' import TransportStream from 'winston-transport' -import * as Sentry from '@sentry/bun' +enum SentrySeverity { + Debug = 'debug', + Log = 'log', + Info = 'info', + Warning = 'warning', + Error = 'error', + Fatal = 'fatal' +} + +const DEFAULT_LEVELS_MAP: SeverityOptions = { + silly: SentrySeverity.Debug, + verbose: SentrySeverity.Debug, + info: SentrySeverity.Info, + debug: SentrySeverity.Debug, + warn: SentrySeverity.Warning, + error: SentrySeverity.Error +} + +export interface SentryTransportOptions + extends TransportStream.TransportStreamOptions { + sentry?: Sentry.BunOptions + levelsMap?: SeverityOptions + skipSentryInit?: boolean +} -interface Context { - level?: any - extra?: any - fingerprint?: any +interface SeverityOptions { + [key: string]: Sentry.SeverityLevel } -const errorHandler = (err: any) => { - // eslint-disable-next-line - console.error(err) +class ExtendedError extends Error { + constructor(info: any) { + super(info.message) + + this.name = info.name || 'Error' + if (info.stack && typeof info.stack === 'string') { + this.stack = info.stack + } + } } -class WinstonSentryTransport extends TransportStream { - protected name: string - protected tags: {[s: string]: any} - protected levelsMap: any +export default class SentryTransport extends TransportStream { + public silent = false + + private levelsMap: SeverityOptions = {} - constructor(opts: any) { + public constructor(opts?: SentryTransportOptions) { super(opts) - this.name = 'winston-sentry-log' - this.tags = {} - const options = opts - - _.defaultsDeep(opts, { - errorHandler, - config: { - dsn: process.env.SENTRY_DSN - }, - level: 'info', - levelsMap: { - silly: 'debug', - verbose: 'debug', - info: 'info', - debug: 'debug', - warn: 'warning', - error: 'error' - }, - name: 'winston-sentry-log', - silent: false + + this.levelsMap = this.setLevelsMap(opts && opts.levelsMap) + this.silent = (opts && opts.silent) || false + + if (!opts || !opts.skipSentryInit) { + Sentry.init(SentryTransport.withDefaults((opts && opts.sentry) || {})) + } + } + + public log(info: any, callback: () => void) { + setImmediate(() => { + this.emit('logged', info) }) - this.levelsMap = options.levelsMap + if (this.silent) return callback() + + const {message, tags, user, ...meta} = info + const winstonLevel = info['level'] + + const sentryLevel = this.levelsMap[winstonLevel] - if (options.tags) { - this.tags = options.tags - } else if (options.globalTags) { - this.tags = options.globalTags - } else if (options.config.tags) { - this.tags = options.config.tags + const scope = Sentry.getCurrentScope() + + scope.clear() + + if (tags !== undefined && SentryTransport.isObject(tags)) { + scope.setTags(tags) } - if (options.extra) { - options.config.extra = options.config.extra || {} - options.config.extra = _.defaults(options.config.extra, options.extra) + scope.setExtras(meta) + + if (user !== undefined && SentryTransport.isObject(user)) { + scope.setUser(user) } - // if (!Sentry.getClient()) { - // Sentry.init({ - // dsn: options.config.dsn, - // environment: process.env.NODE_ENV - // }) - // } - - Sentry.configureScope(scope => { - if (!_.isEmpty(this.tags)) { - Object.keys(this.tags).forEach(key => { - scope.setTag(key, this.tags[key]) - }) - } + // TODO: add fingerprints + // scope.setFingerprint(['{{ default }}', path]); // fingerprint should be an array + + // scope.clear(); + + // TODO: add breadcrumbs + // Sentry.addBreadcrumb({ + // message: 'My Breadcrumb', + // // ... + // }); + + // Capturing Errors / Exceptions + if (SentryTransport.shouldLogException(sentryLevel)) { + const error = + Object.values(info).find(value => value instanceof Error) ?? + new ExtendedError(info) + Sentry.captureException(error, {tags, level: sentryLevel}) + + return callback() + } + + // Capturing Messages + Sentry.captureMessage(message, sentryLevel) + return callback() + } + + end(...args: any[]) { + Sentry.flush().then(() => { + super.end(...args) }) + return this } - public log( - info: any, - callback: any - ): ((a: null, b: boolean) => unknown) | undefined { - const {message, fingerprint} = info + public get sentry() { + return Sentry + } - const level = Object.keys(this.levelsMap).find(key => - info.level.toString().includes(key) - ) - if (!level) { - return callback(null, true) + private setLevelsMap = (options?: SeverityOptions): SeverityOptions => { + if (!options) { + return DEFAULT_LEVELS_MAP } - const meta = Object.assign({}, _.omit(info, ['level', 'message', 'label'])) - setImmediate(() => { - this.emit('logged', level) - }) + const customLevelsMap = Object.keys(options).reduce( + (acc: {[key: string]: any}, winstonSeverity: string) => { + acc[winstonSeverity] = options[winstonSeverity] + return acc + }, + {} + ) - if (!!this.silent) { - return callback(null, true) + return { + ...DEFAULT_LEVELS_MAP, + ...customLevelsMap } + } - const context: Context = {} - context.level = this.levelsMap[level] - context.extra = _.omit(meta, ['user', 'tags']) - context.fingerprint = [fingerprint, process.env.NODE_ENV] - Sentry.withScope(scope => { - const user = _.get(meta, 'user') - if (_.has(context, 'extra')) { - Object.keys(context.extra).forEach(key => { - scope.setExtra(key, context.extra[key]) - }) - } - - if (!_.isEmpty(meta.tags) && _.isObject(meta.tags)) { - Object.keys(meta.tags).forEach(key => { - scope.setTag(key, meta.tags[key]) - }) - } - - if (!!user) { - scope.setUser(user) - } - - if (context.level === 'error' || context.level === 'fatal') { - let err: Error | null = null - if (_.isError(info)) { - err = info - } else { - err = new Error(message) - if (info.stack) { - err.stack = info.stack - } - } - Sentry.captureException(err) - return callback(null, true) - } - Sentry.captureMessage(message, context.level) - return callback(null, true) - }) - return undefined + private static withDefaults(options: Sentry.BunOptions) { + return { + ...options, + dsn: (options && options.dsn) || process.env.SENTRY_DSN || '', + serverName: + (options && options.serverName) || 'winston-transport-sentry-node', + environment: + (options && options.environment) || + process.env.SENTRY_ENVIRONMENT || + process.env.NODE_ENV || + 'production', + debug: (options && options.debug) || !!process.env.SENTRY_DEBUG || false, + sampleRate: (options && options.sampleRate) || 1.0, + maxBreadcrumbs: (options && options.maxBreadcrumbs) || 100 + } } -} -export default WinstonSentryTransport + // private normalizeMessage(msg: any) { + // return msg && msg.message ? msg.message : msg; + // } + + private static isObject(obj: any) { + const type = typeof obj + return type === 'function' || (type === 'object' && !!obj) + } + + private static shouldLogException(level: Sentry.SeverityLevel) { + return level === SentrySeverity.Fatal || level === SentrySeverity.Error + } +}