sentry.ts•4.23 kB
import { APIError } from 'cloudflare'
import { Toucan, zodErrorsIntegration } from 'toucan-js'
import { McpError } from './mcp-error'
import type { BaseTransportOptions, Client, ClientOptions, Event, EventHint } from '@sentry/types'
import type { Context, Next } from 'hono'
import type { Context as SentryContext } from 'toucan-js/dist/types'
import type { MCPEnvironment } from './config'
function is5xxError(status: number): boolean {
return status >= 500 && status <= 599
}
export class SentryClient {
private sentry: Toucan
constructor(sentry: Toucan) {
this.sentry = sentry
}
public recordError(e: unknown) {
if (this.sentry) {
// ignore errors from McpError and APIError (cloudflare) that have reportToSentry = false, or aren't 5xx errors
if (e instanceof McpError) {
if (e.reportToSentry === false) {
return
}
} else if (e instanceof APIError) {
if (!is5xxError(e.status)) {
return
}
}
this.sentry.captureException(e)
}
}
public setUser(userId: string) {
this.sentry.setUser({ ...this.sentry.getUser(), user_id: userId })
}
}
interface BaseBindings {
ENVIRONMENT: MCPEnvironment
GIT_HASH: string
SENTRY_DSN: string
SENTRY_ACCESS_CLIENT_ID: string
SENTRY_ACCESS_CLIENT_SECRET: string
}
export interface BaseHonoContext {
Bindings: BaseBindings
Variables: {
sentry?: SentryClient
}
}
export function initSentry<T extends BaseBindings>(
env: T,
ctx: SentryContext,
req?: Request<unknown, CfProperties>
): SentryClient {
const sentry = new Toucan({
dsn: env.SENTRY_DSN,
request: req,
environment: env.ENVIRONMENT,
context: ctx,
release: env.GIT_HASH,
requestDataOptions: {
allowedHeaders: [
'user-agent',
'cf-challenge',
'accept-encoding',
'accept-language',
'cf-ray',
'content-length',
'content-type',
'host',
],
// Allow ONLY the “scope” param in order to avoid recording jwt, code, state and any other callback params
allowedSearchParams: /^scope$/,
},
integrations: [
zodErrorsIntegration({ saveAttachments: true }),
{
name: 'mcp-api-errors',
processEvent(
event: Event,
_hint: EventHint,
_client: Client<ClientOptions<BaseTransportOptions>>
): Event {
const processedEvent = applyMcpErrorsToEvent(event)
return processedEvent
},
},
],
transportOptions: {
headers: {
'CF-Access-Client-ID': env.SENTRY_ACCESS_CLIENT_ID,
'CF-Access-Client-Secret': env.SENTRY_ACCESS_CLIENT_SECRET,
},
},
})
return new SentryClient(sentry)
}
export function initSentryWithUser<T extends BaseBindings>(
env: T,
ctx: SentryContext,
userId: string,
req?: Request<unknown, CfProperties>
): SentryClient {
const sentryClient = initSentry(env, ctx, req)
sentryClient.setUser(userId)
return sentryClient
}
export async function useSentry<T extends BaseHonoContext>(
c: Context<T>,
next: Next
): Promise<void> {
c.set('sentry', initSentry(c.env, c.executionCtx, c.req.raw))
await next()
}
export function setSentryRequestHeaders(sentry: Toucan, req: Request<unknown, CfProperties>) {
const colo: string = req.cf && typeof req.cf.colo === 'string' ? req.cf.colo : 'UNKNOWN'
sentry.setTag('colo', colo)
const ip_address = req.headers.get('cf-connecting-ip') ?? ''
const userAgent = req.headers.get('user-agent') ?? ''
sentry.setUser({
...sentry.getUser(),
ip_address,
userAgent,
colo,
})
}
function applyMcpErrorsToEvent(event: Event): Event {
if (event.exception === undefined || event.exception.values === undefined) {
return event
}
if (event.exception instanceof McpError) {
try {
return {
...event,
extra: {
...event.extra,
statusCode: event.exception.code,
internalMessage: event.exception.internalMessage,
},
}
} catch (e) {
// Hopefully we never throw errors here, but record it
// with the event just in case.
return {
...event,
extra: {
...event.extra,
'McpError sentry integration parse error': {
message: `an exception was thrown while processing McpError within applyMcpErrorsToEvent()`,
error: e instanceof Error ? `${e.name}: ${e.cause}\n${e.stack}` : 'unknown',
},
},
}
}
}
return event
}