Skip to content

feat(cloudflare): Add instrumentWorkflowWithSentry to instrument workflows #16672

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 9 commits into from
Jun 23, 2025
Merged
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
2 changes: 1 addition & 1 deletion packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
}
},
"devDependencies": {
"@cloudflare/workers-types": "4.20240725.0",
"@cloudflare/workers-types": "4.20250620.0",
"@types/node": "^18.19.1",
"wrangler": "^3.67.1"
},
Expand Down
10 changes: 8 additions & 2 deletions packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,14 @@ export class CloudflareClient extends ServerRuntimeClient<CloudflareClientOption
}
}

// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface BaseCloudflareOptions {}
interface BaseCloudflareOptions {
/**
* @ignore Used internally to disable the deDupeIntegration for workflows.
* @hidden Used internally to disable the deDupeIntegration for workflows.
* @default true
*/
enableDedupe?: boolean;
}

/**
* Configuration options for the Sentry Cloudflare SDK
Expand Down
2 changes: 2 additions & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,6 @@ export { fetchIntegration } from './integrations/fetch';

export { instrumentD1WithSentry } from './d1';

export { instrumentWorkflowWithSentry } from './workflows';

export { setAsyncLocalStorageAsyncContextStrategy } from './async';
4 changes: 3 additions & 1 deletion packages/cloudflare/src/pages-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export function sentryPagesPlugin<
setAsyncLocalStorageAsyncContextStrategy();
return context => {
const options = typeof handlerOrOptions === 'function' ? handlerOrOptions(context) : handlerOrOptions;
return wrapRequestHandler({ options, request: context.request, context }, () => context.next());
return wrapRequestHandler({ options, request: context.request, context: { ...context, props: {} } }, () =>
context.next(),
);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This change was due to updating to the latest Cloudflare types.

ExecutionContext now has a props property which isn't on EventPluginContext.

};
}
4 changes: 3 additions & 1 deletion packages/cloudflare/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import { defaultStackParser } from './vendor/stacktrace';
export function getDefaultIntegrations(options: CloudflareOptions): Integration[] {
const sendDefaultPii = options.sendDefaultPii ?? false;
return [
dedupeIntegration(),
// The Dedupe integration should not be used in workflows because we want to
// capture all step failures, even if they are the same error.
...(options.enableDedupe === false ? [] : [dedupeIntegration()]),
// TODO(v10): Replace with `eventFiltersIntegration` once we remove the deprecated `inboundFiltersIntegration`
// eslint-disable-next-line deprecation/deprecation
inboundFiltersIntegration(),
Expand Down
184 changes: 184 additions & 0 deletions packages/cloudflare/src/workflows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type { PropagationContext } from '@sentry/core';
import {
captureException,
flush,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
startSpan,
withIsolationScope,
withScope,
} from '@sentry/core';
import type {
WorkflowEntrypoint,
WorkflowEvent,
WorkflowSleepDuration,
WorkflowStep,
WorkflowStepConfig,
WorkflowStepEvent,
WorkflowTimeoutDuration,
} from 'cloudflare:workers';
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import type { CloudflareOptions } from './client';
import { addCloudResourceContext } from './scope-utils';
import { init } from './sdk';

const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;

function propagationContextFromInstanceId(instanceId: string): PropagationContext {
// Validate and normalize traceId - should be a valid UUID with or without hyphens
if (!UUID_REGEX.test(instanceId)) {
throw new Error("Invalid 'instanceId' for workflow: Sentry requires random UUIDs for instanceId.");
}

// Remove hyphens to get UUID without hyphens
const traceId = instanceId.replace(/-/g, '');

// Derive sampleRand from last 4 characters of the random UUID
//
// We cannot store any state between workflow steps, so we derive the
// sampleRand from the traceId itself. This ensures that the sampling is
// consistent across all steps in the same workflow instance.
const sampleRand = parseInt(traceId.slice(-4), 16) / 0xffff;

return {
traceId,
sampleRand,
};
}

async function workflowStepWithSentry<V>(
instanceId: string,
options: CloudflareOptions,
callback: () => V,
): Promise<V> {
setAsyncLocalStorageAsyncContextStrategy();

return withIsolationScope(async isolationScope => {
const client = init({ ...options, enableDedupe: false });
isolationScope.setClient(client);

addCloudResourceContext(isolationScope);

return withScope(async scope => {
const propagationContext = propagationContextFromInstanceId(instanceId);
scope.setPropagationContext(propagationContext);

// eslint-disable-next-line no-return-await
return await callback();
});
});
}

class WrappedWorkflowStep implements WorkflowStep {
public constructor(
private _instanceId: string,
private _ctx: ExecutionContext,
private _options: CloudflareOptions,
private _step: WorkflowStep,
) {}

public async do<T extends Rpc.Serializable<T>>(name: string, callback: () => Promise<T>): Promise<T>;
public async do<T extends Rpc.Serializable<T>>(
name: string,
config: WorkflowStepConfig,
callback: () => Promise<T>,
): Promise<T>;
public async do<T extends Rpc.Serializable<T>>(
name: string,
configOrCallback: WorkflowStepConfig | (() => Promise<T>),
maybeCallback?: () => Promise<T>,
): Promise<T> {
const userCallback = (maybeCallback || configOrCallback) as () => Promise<T>;
const config = typeof configOrCallback === 'function' ? undefined : configOrCallback;

const instrumentedCallback: () => Promise<T> = async () => {
return workflowStepWithSentry(this._instanceId, this._options, async () => {
return startSpan(
{
op: 'function.step.do',
name,
attributes: {
'cloudflare.workflow.timeout': config?.timeout,
'cloudflare.workflow.retries.backoff': config?.retries?.backoff,
'cloudflare.workflow.retries.delay': config?.retries?.delay,
'cloudflare.workflow.retries.limit': config?.retries?.limit,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task',
},
},
async span => {
try {
const result = await userCallback();
span.setStatus({ code: 1 });
return result;
} catch (error) {
captureException(error, { mechanism: { handled: true, type: 'cloudflare' } });
throw error;
} finally {
this._ctx.waitUntil(flush(2000));
}
},
);
});
};

return config ? this._step.do(name, config, instrumentedCallback) : this._step.do(name, instrumentedCallback);
}

public async sleep(name: string, duration: WorkflowSleepDuration): Promise<void> {
return this._step.sleep(name, duration);
}

public async sleepUntil(name: string, timestamp: Date | number): Promise<void> {
return this._step.sleepUntil(name, timestamp);
}

public async waitForEvent<T extends Rpc.Serializable<T>>(
name: string,
options: { type: string; timeout?: WorkflowTimeoutDuration | number },
): Promise<WorkflowStepEvent<T>> {
return this._step.waitForEvent<T>(name, options);
}
}

/**
* Instruments a Cloudflare Workflow class with Sentry.
*
* @example
* ```typescript
* const InstrumentedWorkflow = instrumentWorkflowWithSentry(
* (env) => ({ dsn: env.SENTRY_DSN }),
* MyWorkflowClass
* );
*
* export default InstrumentedWorkflow;
* ```
*
* @param optionsCallback - Function that returns Sentry options to initialize Sentry
* @param WorkflowClass - The workflow class to instrument
* @returns Instrumented workflow class with the same interface
*/
export function instrumentWorkflowWithSentry<
E, // Environment type
P, // Payload type
T extends WorkflowEntrypoint<E, P>, // WorkflowEntrypoint type
C extends new (ctx: ExecutionContext, env: E) => T, // Constructor type of the WorkflowEntrypoint class
>(optionsCallback: (env: E) => CloudflareOptions, WorkFlowClass: C): C {
return new Proxy(WorkFlowClass, {
construct(target: C, args: [ctx: ExecutionContext, env: E], newTarget) {
const [ctx, env] = args;
const options = optionsCallback(env);
const instance = Reflect.construct(target, args, newTarget) as T;
return new Proxy(instance, {
get(obj, prop, receiver) {
if (prop === 'run') {
return async function (event: WorkflowEvent<P>, step: WorkflowStep): Promise<unknown> {
return obj.run.call(obj, event, new WrappedWorkflowStep(event.instanceId, ctx, options, step));
};
}
return Reflect.get(obj, prop, receiver);
},
});
},
}) as C;
}
Loading
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy