Skip to content

Conversation

@TooTallNate
Copy link
Member

@TooTallNate TooTallNate commented Jan 19, 2026

Web example:

dark mode
Screenshot 2026-01-20 at 01 38 04

light mode
Screenshot 2026-01-20 at 01 38 09

CLI example:

Screenshot 2026-01-20 at 01 44 53

Added support for displaying custom class instances in the observability UI by showing opaque markers when deserializing custom classes.

What changed?

  • Added a CLASS_INSTANCE_REF_TYPE marker and related interfaces to represent serialized class instances that cannot be fully deserialized
  • Implemented helper functions to extract class names from class IDs and convert serialized instances to reference objects
  • Enhanced the stream print revivers to handle custom class instances and class references
  • Updated the attribute panel to recognize and display class instance references with a __class__ marker alongside the serialized data

How to test?

  1. Create a workflow that uses custom classes
  2. Run the workflow and view it in the observability UI
  3. Verify that custom class instances are displayed with their class names and serialized data
  4. Check that class references are shown as <class:ClassName> in the UI

Why make this change?

Previously, custom class instances couldn't be properly displayed in the observability UI because they weren't registered for deserialization in the o11y context. This change improves the developer experience by providing meaningful representations of custom classes and instances, making it easier to debug and understand workflow execution without requiring class registration.

@changeset-bot
Copy link

changeset-bot bot commented Jan 19, 2026

🦋 Changeset detected

Latest commit: c889dd1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@workflow/web-shared Patch
@workflow/core Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/docs-typecheck Patch
@workflow/next Patch
@workflow/nitro Patch
workflow Patch
@workflow/astro Patch
@workflow/sveltekit Patch
@workflow/world-testing Patch
@workflow/nuxt Patch
@workflow/ai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Jan 19, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 435 0 38 473
❌ 💻 Local Development 396 2 32 430
✅ 📦 Local Production 398 0 32 430
✅ 🐘 Local Postgres 398 0 32 430
✅ 🪟 Windows 43 0 0 43
❌ 🌍 Community Worlds 162 22 0 184
Total 1832 24 134 1990

❌ Failed Tests

💻 Local Development (2 failed)

sveltekit-stable (2 failed):

  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
🌍 Community Worlds (22 failed)

mongodb (1 failed):

  • webhookWorkflow

redis (1 failed):

  • webhookWorkflow

starter (19 failed):

  • addTenWorkflow
  • addTenWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE

turso (1 failed):

  • webhookWorkflow

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 39 0 4
✅ example 39 0 4
✅ express 39 0 4
✅ fastify 39 0 4
✅ hono 39 0 4
✅ nextjs-turbopack 42 0 1
✅ nextjs-webpack 42 0 1
✅ nitro 39 0 4
✅ nuxt 39 0 4
✅ sveltekit 39 0 4
✅ vite 39 0 4
❌ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 39 0 4
✅ express-stable 39 0 4
✅ fastify-stable 39 0 4
✅ hono-stable 39 0 4
✅ nextjs-turbopack-stable 43 0 0
✅ nextjs-webpack-stable 43 0 0
✅ nitro-stable 39 0 4
✅ nuxt-stable 39 0 4
❌ sveltekit-stable 37 2 4
✅ vite-stable 39 0 4
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 39 0 4
✅ express-stable 39 0 4
✅ fastify-stable 39 0 4
✅ hono-stable 39 0 4
✅ nextjs-turbopack-stable 43 0 0
✅ nextjs-webpack-stable 43 0 0
✅ nitro-stable 39 0 4
✅ nuxt-stable 39 0 4
✅ sveltekit-stable 39 0 4
✅ vite-stable 39 0 4
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 39 0 4
✅ express-stable 39 0 4
✅ fastify-stable 39 0 4
✅ hono-stable 39 0 4
✅ nextjs-turbopack-stable 43 0 0
✅ nextjs-webpack-stable 43 0 0
✅ nitro-stable 39 0 4
✅ nuxt-stable 39 0 4
✅ sveltekit-stable 39 0 4
✅ vite-stable 39 0 4
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 43 0 0
❌ 🌍 Community Worlds
App Passed Failed Skipped
✅ mongodb-dev 3 0 0
❌ mongodb 42 1 0
✅ redis-dev 3 0 0
❌ redis 42 1 0
✅ starter-dev 3 0 0
❌ starter 24 19 0
✅ turso-dev 3 0 0
❌ turso 42 1 0

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: failure
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

Copy link
Member Author

TooTallNate commented Jan 19, 2026

@TooTallNate TooTallNate marked this pull request as ready for review January 19, 2026 22:17
Copilot AI review requested due to automatic review settings January 19, 2026 22:17
@vercel
Copy link
Contributor

vercel bot commented Jan 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jan 20, 2026 9:51am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jan 20, 2026 9:51am
example-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-astro-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-express-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-fastify-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-hono-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-nitro-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-nuxt-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workbench-vite-workflow Ready Ready Preview, Comment Jan 20, 2026 9:51am
workflow-docs Ready Ready Preview, Comment Jan 20, 2026 9:51am

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR enhances the observability UI to display custom class instances that cannot be fully deserialized because their classes are not registered in the o11y context. The implementation adds opaque markers for both class instances and class references, making it easier to debug workflows that use custom classes.

Changes:

  • Added CLASS_INSTANCE_REF_TYPE marker, ClassInstanceRef interface, and type guards to represent unregistered class instances
  • Implemented helper functions (extractClassName, serializedInstanceToRef, serializedClassToString) to convert serialized class data to display-friendly formats
  • Extended streamPrintRevivers to handle Instance and Class types by converting them to opaque markers
  • Updated UI transformation logic to display class instances inline with a __class__ marker alongside their serialized data

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.

File Description
packages/core/src/observability.ts Adds ClassInstanceRef types, helper functions for class name extraction, and stream print revivers for Instance and Class types
packages/web-shared/src/sidebar/attribute-panel.tsx Duplicates ClassInstanceRef types for client-side use and updates transformValueForDisplay to handle class instance refs inline with class marker
.changeset/forty-tables-lick.md Documents the patch-level changes for both packages

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

'__type' in value &&
value.__type === CLASS_INSTANCE_REF_TYPE &&
'className' in value &&
typeof value.className === 'string'
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The type guard isClassInstanceRef validates className but not classId. For consistency with isStreamRef which validates all required fields, and to ensure type safety, the function should also validate that classId exists and is a string.

Suggested change
typeof value.className === 'string'
typeof value.className === 'string' &&
'classId' in value &&
typeof value.classId === 'string'

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +165
const extractClassName = (classId: string): string => {
if (!classId) return 'Unknown';
const parts = classId.split('/');
return parts[parts.length - 1] || classId;
};
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The extractClassName function returns 'Unknown' when classId is falsy, but an empty string is falsy in JavaScript. When classId is an empty string, the function would return 'Unknown' without trying to split it. However, if the split produces an empty array or the last part is an empty string, it would fall back to returning the original classId. Consider handling the empty string case explicitly before the split operation to ensure consistent behavior.

Copilot uses AI. Check for mistakes.
Comment on lines 244 to 262
return {
__class__: v.className,
...(typeof v.data === 'object' && v.data !== null
? v.data
: { value: v.data }),
};
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The spread operator on line 247 could potentially overwrite the class property if v.data is an object that contains a class key. This would result in the class name information being lost. Consider using a different marker name that's less likely to collide with actual object properties, or ensure the class property cannot be overwritten by spreading v.data after it instead of before.

Suggested change
return {
__class__: v.className,
...(typeof v.data === 'object' && v.data !== null
? v.data
: { value: v.data }),
};
return typeof v.data === 'object' && v.data !== null
? {
...(v.data as Record<string, unknown>),
__class__: v.className,
}
: {
value: v.data,
__class__: v.className,
};

Copilot uses AI. Check for mistakes.
Comment on lines 244 to 261
return {
__class__: v.className,
...(typeof v.data === 'object' && v.data !== null
? v.data
: { value: v.data }),
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The v.data object is spread directly without being transformed recursively. If v.data contains nested StreamRef or ClassInstanceRef objects, they won't be properly handled. The nested data should be recursively transformed before spreading to ensure all special references are properly converted.

Suggested change
return {
__class__: v.className,
...(typeof v.data === 'object' && v.data !== null
? v.data
: { value: v.data }),
const transformedData =
typeof v.data === 'object' && v.data !== null
? transform(v.data)
: transform(v.data);
return {
__class__: v.className,
...(typeof v.data === 'object' && v.data !== null
? (transformedData as Record<string, unknown>)
: { value: transformedData }),

Copilot uses AI. Check for mistakes.
Comment on lines +151 to 193
const serializedClassToString = (value: { classId: string }): string => {
const className = extractClassName(value.classId);
return `<class:${className}>`;
};

/**
* This is an extra reviver for devalue that takes any streams that would be converted,
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The serializedClassToString function doesn't validate that the input value has the expected structure. If value.classId is undefined or not a string, extractClassName will handle it, but it would be clearer to add explicit validation to ensure the function is called with the expected shape of data.

Suggested change
const serializedClassToString = (value: { classId: string }): string => {
const className = extractClassName(value.classId);
return `<class:${className}>`;
};
/**
* This is an extra reviver for devalue that takes any streams that would be converted,
* Convert a serialized class reference to a string representation.
* This is used for Class type (the constructor reference itself, not an instance).
*/
const serializedClassToString = (value: unknown): string => {
if (
!value ||
typeof value !== 'object' ||
!('classId' in value) ||
typeof (value as { classId: unknown }).classId !== 'string'
) {
return '<class:Unknown>';
}
const classId = (value as { classId: string }).classId;
const className = extractClassName(classId);
return `<class:${className}>`;
};

Copilot uses AI. Check for mistakes.
Comment on lines 139 to 143
return {
__type: CLASS_INSTANCE_REF_TYPE,
className: extractClassName(value.classId),
classId: value.classId,
data: value.data,
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The serializedInstanceToRef function doesn't validate that the input value has the expected structure. While the type annotation suggests the expected shape, adding runtime validation would make the function more robust, especially since it's called from the streamPrintRevivers which receives values of type any.

Suggested change
return {
__type: CLASS_INSTANCE_REF_TYPE,
className: extractClassName(value.classId),
classId: value.classId,
data: value.data,
// Runtime validation because this function is ultimately called with values of type `any`.
const isObject = value !== null && typeof value === 'object';
const classId = isObject && typeof (value as any).classId === 'string'
? (value as any).classId
: '';
const data = isObject ? (value as any).data : value;
return {
__type: CLASS_INSTANCE_REF_TYPE,
className: extractClassName(classId),
classId,
data,

Copilot uses AI. Check for mistakes.
Comment on lines 120 to 190
/**
* Extract the class name from a classId.
* The classId format is typically "path/to/file/ClassName" so we extract the last segment.
*/
const extractClassName = (classId: string): string => {
if (!classId) return 'Unknown';
const parts = classId.split('/');
return parts[parts.length - 1] || classId;
};

/**
* Convert a serialized class instance to a ClassInstanceRef for o11y display.
* This allows viewing custom class instances in the UI without needing
* the class to be registered for deserialization.
*/
const serializedInstanceToRef = (value: {
classId: string;
data: unknown;
}): ClassInstanceRef => {
return {
__type: CLASS_INSTANCE_REF_TYPE,
className: extractClassName(value.classId),
classId: value.classId,
data: value.data,
};
};

/**
* Convert a serialized class reference to a string representation.
* This is used for Class type (the constructor reference itself, not an instance).
*/
const serializedClassToString = (value: { classId: string }): string => {
const className = extractClassName(value.classId);
return `<class:${className}>`;
};
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The new helper functions extractClassName, serializedInstanceToRef, and serializedClassToString lack test coverage. Given that other utility functions in the codebase have comprehensive test files (e.g., serialization.test.ts, parse-name.test.ts), consider adding tests to verify the behavior of these functions, especially edge cases like empty classId, classIds with different path separators, and handling of various data types.

Copilot uses AI. Check for mistakes.
'__type' in value &&
value.__type === CLASS_INSTANCE_REF_TYPE &&
'className' in value &&
typeof value.className === 'string'
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

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

The type guard isClassInstanceRef validates className but not classId. For consistency with isStreamRef which validates all required fields, and to ensure type safety, the function should also validate that classId exists and is a string. This would prevent runtime errors if a malformed ClassInstanceRef object is encountered.

Suggested change
typeof value.className === 'string'
typeof value.className === 'string' &&
'classId' in value &&
typeof value.classId === 'string'

Copilot uses AI. Check for mistakes.
@TooTallNate TooTallNate force-pushed the 01-19-show_opaque_marker_when_deserializing_custom_classes_and_instances_in_o11y branch from 127cfec to c46327f Compare January 19, 2026 22:26
@TooTallNate TooTallNate force-pushed the 01-09-add_support_for_custom_class_instance_serialization branch from baea85a to 4216347 Compare January 19, 2026 22:26
value: unknown
): { json: string; streamRefs: Map<string, StreamRef> } => {
): {
json: string;
Copy link
Contributor

@vercel vercel bot Jan 19, 2026

Choose a reason for hiding this comment

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

ClassInstanceRef transformation allows properties in v.data to overwrite the class marker, and doesn't recursively transform nested ClassInstanceRef objects within v.data

Fix on Vercel

* Used in o11y when a custom class instance is encountered but the class is not
* registered for deserialization.
*/
interface ClassInstanceRef {
Copy link
Contributor

@vercel vercel bot Jan 19, 2026

Choose a reason for hiding this comment

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

Type guard isClassInstanceRef doesn't validate classId and data properties required by ClassInstanceRef interface, leading to type unsafety

Fix on Vercel

'__type' in value &&
value.__type === CLASS_INSTANCE_REF_TYPE &&
'className' in value &&
typeof value.className === 'string'
Copy link
Contributor

@vercel vercel bot Jan 19, 2026

Choose a reason for hiding this comment

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

The isClassInstanceRef type guard doesn't validate classId and data properties required by the ClassInstanceRef interface

Fix on Vercel

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Nathan Rajlich <n@n8.io>
@TooTallNate TooTallNate merged commit f93e894 into main Jan 20, 2026
23 checks passed
@TooTallNate TooTallNate deleted the 01-19-show_opaque_marker_when_deserializing_custom_classes_and_instances_in_o11y branch January 20, 2026 09:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants