Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
30 changes: 30 additions & 0 deletions contrib/interceptors-opentelemetry-v2/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@ import type {
WorkflowTerminateInput,
WorkflowCancelInput,
WorkflowDescribeInput,
WorkflowFetchHistoryInput,
WorkflowListInput,
WorkflowClientInterceptor,
TerminateWorkflowExecutionResponse,
RequestCancelWorkflowExecutionResponse,
DescribeWorkflowExecutionResponse,
WorkflowExecutionInfo,
} from '@temporalio/client';
import type { History } from '@temporalio/common/lib/proto-utils';
import {
instrument,
instrumentSync,
headersWithContext,
RUN_ID_ATTR_KEY,
WORKFLOW_ID_ATTR_KEY,
Expand Down Expand Up @@ -217,4 +222,29 @@ export class OpenTelemetryWorkflowClientInterceptor implements WorkflowClientInt
},
});
}

async fetchHistory(
input: WorkflowFetchHistoryInput,
next: Next<WorkflowClientInterceptor, 'fetchHistory'>
): Promise<History> {
return await instrument({
tracer: this.tracer,
spanName: SpanName.WORKFLOW_FETCH_HISTORY,
fn: async (span) => {
span.setAttribute(WORKFLOW_ID_ATTR_KEY, input.workflowExecution.workflowId);
if (input.workflowExecution.runId) {
span.setAttribute(RUN_ID_ATTR_KEY, input.workflowExecution.runId);
}
return await next(input);
},
});
}

list(input: WorkflowListInput, next: Next<WorkflowClientInterceptor, 'list'>): AsyncIterable<WorkflowExecutionInfo> {
return instrumentSync({
tracer: this.tracer,
spanName: SpanName.WORKFLOW_LIST,
fn: () => next(input),
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ export enum SpanName {
*/
WORKFLOW_DESCRIBE = 'DescribeWorkflow',

/**
* Workflow history is fetched
*/
WORKFLOW_FETCH_HISTORY = 'FetchWorkflowHistory',

/**
* Workflows are listed
*/
WORKFLOW_LIST = 'ListWorkflows',

/**
* Workflow run is executing
*/
Expand Down
30 changes: 30 additions & 0 deletions contrib/interceptors-opentelemetry/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@ import type {
WorkflowTerminateInput,
WorkflowCancelInput,
WorkflowDescribeInput,
WorkflowFetchHistoryInput,
WorkflowListInput,
WorkflowClientInterceptor,
TerminateWorkflowExecutionResponse,
RequestCancelWorkflowExecutionResponse,
DescribeWorkflowExecutionResponse,
WorkflowExecutionInfo,
} from '@temporalio/client';
import type { History } from '@temporalio/common/lib/proto-utils';
import {
instrument,
instrumentSync,
headersWithContext,
RUN_ID_ATTR_KEY,
WORKFLOW_ID_ATTR_KEY,
Expand Down Expand Up @@ -215,4 +220,29 @@ export class OpenTelemetryWorkflowClientInterceptor implements WorkflowClientInt
},
});
}

async fetchHistory(
input: WorkflowFetchHistoryInput,
next: Next<WorkflowClientInterceptor, 'fetchHistory'>
): Promise<History> {
return await instrument({
tracer: this.tracer,
spanName: SpanName.WORKFLOW_FETCH_HISTORY,
fn: async (span) => {
span.setAttribute(WORKFLOW_ID_ATTR_KEY, input.workflowExecution.workflowId);
if (input.workflowExecution.runId) {
span.setAttribute(RUN_ID_ATTR_KEY, input.workflowExecution.runId);
}
return await next(input);
},
});
}

list(input: WorkflowListInput, next: Next<WorkflowClientInterceptor, 'list'>): AsyncIterable<WorkflowExecutionInfo> {
return instrumentSync({
tracer: this.tracer,
spanName: SpanName.WORKFLOW_LIST,
fn: () => next(input),
});
}
}
10 changes: 10 additions & 0 deletions contrib/interceptors-opentelemetry/src/workflow/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ export enum SpanName {
*/
WORKFLOW_DESCRIBE = 'DescribeWorkflow',

/**
* Workflow history is fetched
*/
WORKFLOW_FETCH_HISTORY = 'FetchWorkflowHistory',

/**
* Workflows are listed
*/
WORKFLOW_LIST = 'ListWorkflows',

/**
* Workflow run is executing
*/
Expand Down
21 changes: 21 additions & 0 deletions packages/client/src/interceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import type { Duration, SearchAttributePair, TypedSearchAttributes } from '@temporalio/common';
import { Headers, Next } from '@temporalio/common';
import type { History } from '@temporalio/common/lib/proto-utils';
import type { temporal } from '@temporalio/proto';
import type { NexusOperationHandle } from './nexus-client';
import type {
Expand All @@ -24,6 +25,7 @@ import type {
RequestCancelWorkflowExecutionResponse,
TerminateWorkflowExecutionResponse,
WorkflowExecution,
WorkflowExecutionInfo,
} from './types';
import type { CompiledWorkflowOptions, WorkflowUpdateOptions } from './workflow-options';
import type { ActivityHandle, ActivityOptions } from './activity-client';
Expand Down Expand Up @@ -128,6 +130,17 @@ export interface WorkflowDescribeInput {
readonly workflowExecution: WorkflowExecution;
}

/** Input for WorkflowClientInterceptor.fetchHistory */
export interface WorkflowFetchHistoryInput {
readonly workflowExecution: WorkflowExecution;
}

/** Input for WorkflowClientInterceptor.list */
export interface WorkflowListInput {
readonly query?: string;
readonly pageSize?: number;
}

/**
* Implement any of these methods to intercept {@link WorkflowClient} outbound calls
*
Expand Down Expand Up @@ -198,6 +211,14 @@ export interface WorkflowClientInterceptor {
* Intercept a service call to describeWorkflowExecution
*/
describe?: (input: WorkflowDescribeInput, next: Next<this, 'describe'>) => Promise<DescribeWorkflowExecutionResponse>;
/**
* Intercept a service call to getWorkflowExecutionHistory
*/
fetchHistory?: (input: WorkflowFetchHistoryInput, next: Next<this, 'fetchHistory'>) => Promise<History>;
/**
* Intercept a service call to listWorkflowExecutions
*/
list?: (input: WorkflowListInput, next: Next<this, 'list'>) => AsyncIterable<WorkflowExecutionInfo>;
}

/** @deprecated: Use {@link WorkflowClientInterceptor} instead */
Expand Down
62 changes: 43 additions & 19 deletions packages/client/src/workflow-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import type {
WorkflowClientInterceptor,
WorkflowClientInterceptors,
WorkflowDescribeInput,
WorkflowFetchHistoryInput,
WorkflowListInput,
WorkflowQueryInput,
WorkflowSignalInput,
WorkflowSignalWithStartInput,
Expand Down Expand Up @@ -1568,6 +1570,28 @@ export class WorkflowClient extends BaseClient {
}
}

/**
* Uses given input to make getWorkflowExecutionHistory call(s) to the service
*
* Used as the final function of the fetchHistory interceptor chain
*/
protected async _fetchHistoryHandler(input: WorkflowFetchHistoryInput): Promise<History> {
let nextPageToken: Uint8Array | undefined = undefined;
const events = Array<temporal.api.history.v1.IHistoryEvent>();
for (;;) {
const response: temporal.api.workflowservice.v1.GetWorkflowExecutionHistoryResponse =
await this.workflowService.getWorkflowExecutionHistory({
nextPageToken,
namespace: this.options.namespace,
execution: input.workflowExecution,
});
events.push(...(response.history?.events ?? []));
nextPageToken = response.nextPageToken;
if (nextPageToken == null || nextPageToken.length === 0) break;
}
return temporal.api.history.v1.History.create({ events });
}

/**
* Create a new workflow handle for new or existing Workflow execution
*/
Expand Down Expand Up @@ -1656,20 +1680,11 @@ export class WorkflowClient extends BaseClient {
};
},
async fetchHistory() {
let nextPageToken: Uint8Array | undefined = undefined;
const events = Array<temporal.api.history.v1.IHistoryEvent>();
for (;;) {
const response: temporal.api.workflowservice.v1.GetWorkflowExecutionHistoryResponse =
await this.client.workflowService.getWorkflowExecutionHistory({
nextPageToken,
namespace: this.client.options.namespace,
execution: { workflowId, runId },
});
events.push(...(response.history?.events ?? []));
nextPageToken = response.nextPageToken;
if (nextPageToken == null || nextPageToken.length === 0) break;
}
return temporal.api.history.v1.History.create({ events });
const next = this.client._fetchHistoryHandler.bind(this.client);
const fn = composeInterceptors(interceptors, 'fetchHistory', next);
return await fn({
workflowExecution: { workflowId, runId },
});
},
async startUpdate<Ret, Args extends any[]>(
def: UpdateDefinition<Ret, Args> | string,
Expand Down Expand Up @@ -1753,16 +1768,16 @@ export class WorkflowClient extends BaseClient {
});
}

protected async *_list(options?: ListOptions): AsyncIterable<WorkflowExecutionInfo> {
protected async *_list(input: WorkflowListInput): AsyncIterable<WorkflowExecutionInfo> {
let nextPageToken: Uint8Array = Buffer.alloc(0);
for (;;) {
let response: temporal.api.workflowservice.v1.ListWorkflowExecutionsResponse;
try {
response = await this.workflowService.listWorkflowExecutions({
namespace: this.options.namespace,
query: options?.query,
query: input.query,
nextPageToken,
pageSize: options?.pageSize,
pageSize: input.pageSize,
});
} catch (e) {
this.rethrowGrpcError(e, 'Failed to list workflows', undefined);
Expand All @@ -1787,11 +1802,20 @@ export class WorkflowClient extends BaseClient {
* https://docs.temporal.io/visibility
*/
public list(options?: ListOptions): AsyncWorkflowListIterable {
const input: WorkflowListInput = {
query: options?.query,
pageSize: options?.pageSize,
};
// Deprecated interceptor factories require a workflowId and are skipped for client-level list.
const interceptors = Array.isArray(this.options.interceptors)
? (this.options.interceptors as WorkflowClientInterceptor[])
: [];
const list = composeInterceptors(interceptors, 'list', this._list.bind(this));
return {
[Symbol.asyncIterator]: () => this._list(options)[Symbol.asyncIterator](),
[Symbol.asyncIterator]: () => list(input)[Symbol.asyncIterator](),
intoHistories: (intoHistoriesOptions?: IntoHistoriesOptions) => {
return mapAsyncIterable(
this._list(options),
list(input),
async ({ workflowId, runId }) => ({
workflowId,
history: await this.getHandle(workflowId, runId).fetchHistory(),
Expand Down
41 changes: 41 additions & 0 deletions packages/test/src/test-interceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,47 @@ if (RUN_INTEGRATION_TESTS) {
});
});

test.serial('WorkflowClientInterceptor intercepts list and fetchHistory', async (t) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the list() hook will need more coverage than this. The fact that it is a generator adds some complications. Plus, we'll need to test interception in the case of list().withHistories().

Let's try this. Start a few workflow executions (~10), then do for await (const _ of client.list({ /* query is not important */, pageSize: 2 })) {. That would require >5 requests to the server to see all search result. But then, in the loop, break once you've seen 3 pages, without reaching the end of the generator.

I would expect the list interceptor to have been called only once in that sequence. And given the current test implementation (interceptor function is sync), I'm pretty sure the list interceptor function will return immediately.

Does that "return immediately" behavior really what we need for list interceptors? For example, will OTel correctly figure out the encapsulation and timeline of the list call? I'm really not sure about that. My intuition is that the list interceptor would have to be a generator itself, proxying each request to the downstream AsyncIterator obtained by calling next().

Also, do the same thing with for await (const _ of client.list({ /* query is not important */, pageSize: 2 }).intoHistories()) {. In addition to the list() call, confirm that you're seeing >6 calls to fetchHistory.

const taskQueue = 'test-interceptor-list-and-fetch-history';
const workflowId = randomUUID();
const worker = await Worker.create({
...defaultOptions,
taskQueue,
});
let listCalls = 0;
let fetchHistoryCalls = 0;
const client = new WorkflowClient({
interceptors: [
{
list(input, next) {
listCalls += 1;
return next(input);
},
async fetchHistory(input, next) {
fetchHistoryCalls += 1;
return next(input);
},
},
],
});

await worker.runUntil(async () => {
await client.execute(successString, {
taskQueue,
workflowId,
});

const history = await client.getHandle(workflowId).fetchHistory();
t.true((history.events?.length ?? 0) > 0);
t.is(fetchHistoryCalls, 1);

for await (const _ of client.list({ query: `WorkflowId = "${workflowId}"` })) {
// consume iterator
}
t.is(listCalls, 1);
});
});

test.serial('Workflow continueAsNew can be intercepted', async (t) => {
const taskQueue = 'test-continue-as-new-interceptor';
const worker = await Worker.create({
Expand Down