From c8c222ff116597d2a768cdfb25eece088f0be1a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 11:42:50 +0000 Subject: [PATCH 1/2] Initial plan From 90656ef62d37bf62a47b1b40ee7ed7aacfb6d26d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 11:50:55 +0000 Subject: [PATCH 2/2] feat: add Identity, Causation, and CorrelationId per-call-context managers Agent-Logs-Url: https://github.com/Cratis/Chronicle.TypeScript/sessions/31eac120-e56a-4112-96c1-db8a68a12366 Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/Auditing/Causation.ts | 29 +++++++ Source/Auditing/CausationManager.ts | 51 ++++++++++++ Source/Auditing/CausationType.ts | 38 +++++++++ Source/Auditing/ICausationManager.ts | 29 +++++++ Source/Auditing/index.ts | 15 ++++ Source/Correlation/CorrelationId.ts | 33 ++++++++ Source/Correlation/CorrelationIdManager.ts | 30 +++++++ Source/Correlation/ICorrelationIdAccessor.ts | 15 ++++ Source/Correlation/ICorrelationIdSetter.ts | 21 +++++ Source/Correlation/index.ts | 15 ++++ Source/EventSequences/EventSequence.ts | 84 ++++++++++++-------- Source/Identity/IIdentityProvider.ts | 26 ++++++ Source/Identity/Identity.ts | 61 ++++++++++++++ Source/Identity/IdentityProvider.ts | 28 +++++++ Source/Identity/index.ts | 14 ++++ Source/index.ts | 3 + 16 files changed, 457 insertions(+), 35 deletions(-) create mode 100644 Source/Auditing/Causation.ts create mode 100644 Source/Auditing/CausationManager.ts create mode 100644 Source/Auditing/CausationType.ts create mode 100644 Source/Auditing/ICausationManager.ts create mode 100644 Source/Auditing/index.ts create mode 100644 Source/Correlation/CorrelationId.ts create mode 100644 Source/Correlation/CorrelationIdManager.ts create mode 100644 Source/Correlation/ICorrelationIdAccessor.ts create mode 100644 Source/Correlation/ICorrelationIdSetter.ts create mode 100644 Source/Correlation/index.ts create mode 100644 Source/Identity/IIdentityProvider.ts create mode 100644 Source/Identity/Identity.ts create mode 100644 Source/Identity/IdentityProvider.ts create mode 100644 Source/Identity/index.ts diff --git a/Source/Auditing/Causation.ts b/Source/Auditing/Causation.ts new file mode 100644 index 0000000..cd90a9d --- /dev/null +++ b/Source/Auditing/Causation.ts @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { CausationType } from './CausationType'; + +/** + * Represents a causation instance. + */ +export class Causation { + /** + * Creates an unknown causation instance. + * @returns A new {@link Causation} with the current time, type set to {@link CausationType.unknown}, and empty properties. + */ + static unknown(): Causation { + return new Causation(new Date(), CausationType.unknown, {}); + } + + /** + * Initializes a new instance of the {@link Causation} class. + * @param occurred - When it occurred. + * @param type - Type of causation. + * @param properties - Any properties associated with the causation. + */ + constructor( + readonly occurred: Date, + readonly type: CausationType, + readonly properties: Readonly> + ) {} +} diff --git a/Source/Auditing/CausationManager.ts b/Source/Auditing/CausationManager.ts new file mode 100644 index 0000000..a500e2f --- /dev/null +++ b/Source/Auditing/CausationManager.ts @@ -0,0 +1,51 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { AsyncLocalStorage } from 'async_hooks'; +import { Causation } from './Causation'; +import { CausationType } from './CausationType'; +import { ICausationManager } from './ICausationManager'; + +/** + * Implements {@link ICausationManager} using {@link AsyncLocalStorage} to scope the causation chain to the active async call context. + */ +export class CausationManager implements ICausationManager { + private readonly _storage = new AsyncLocalStorage(); + private _root: Causation = new Causation(new Date(), CausationType.root, {}); + + /** @inheritdoc */ + get root(): Causation { + return this._root; + } + + /** @inheritdoc */ + getCurrentChain(): ReadonlyArray { + const chain = this._getOrInitChain(); + return chain; + } + + /** @inheritdoc */ + add(type: CausationType, properties: Record): void { + const chain = this._getOrInitChain(); + chain.push(new Causation(new Date(), type, properties)); + } + + /** + * Defines the root causation for the current process. + * @param properties - Properties associated with the root causation. + */ + defineRoot(properties: Record): void { + this._root = new Causation(new Date(), CausationType.root, properties); + } + + private _getOrInitChain(): Causation[] { + let chain = this._storage.getStore(); + if (chain === undefined) { + chain = [this._root]; + this._storage.enterWith(chain); + } else if (chain.length === 0) { + chain.push(this._root); + } + return chain; + } +} diff --git a/Source/Auditing/CausationType.ts b/Source/Auditing/CausationType.ts new file mode 100644 index 0000000..3fda6dd --- /dev/null +++ b/Source/Auditing/CausationType.ts @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Represents a type of causation. + */ +export class CausationType { + /** + * Represents the root causation type. + */ + static readonly root = new CausationType('Root'); + + /** + * Represents the unknown causation type. + */ + static readonly unknown = new CausationType('Unknown'); + + /** + * Represents the causation type for a single event append via the TypeScript client. + */ + static readonly appendEvent = new CausationType('TypeScriptClient.Append'); + + /** + * Represents the causation type for a batch event append via the TypeScript client. + */ + static readonly appendManyEvents = new CausationType('TypeScriptClient.AppendMany'); + + /** + * Initializes a new instance of the {@link CausationType} class. + * @param name - The name of the causation type. + */ + constructor(readonly name: string) {} + + /** @inheritdoc */ + toString(): string { + return this.name; + } +} diff --git a/Source/Auditing/ICausationManager.ts b/Source/Auditing/ICausationManager.ts new file mode 100644 index 0000000..712ebe6 --- /dev/null +++ b/Source/Auditing/ICausationManager.ts @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Causation } from './Causation'; +import { CausationType } from './CausationType'; + +/** + * Defines a system that manages causation for the active call context. + */ +export interface ICausationManager { + /** + * Gets the root causation. + */ + readonly root: Causation; + + /** + * Gets the full causation chain for the current call context. + * The chain always starts with {@link root} if no other causation has been added. + * @returns An array of {@link Causation} representing the current chain. + */ + getCurrentChain(): ReadonlyArray; + + /** + * Adds a causation entry to the current chain. + * @param type - The type of causation to add. + * @param properties - Properties associated with the causation. + */ + add(type: CausationType, properties: Record): void; +} diff --git a/Source/Auditing/index.ts b/Source/Auditing/index.ts new file mode 100644 index 0000000..9ac0df2 --- /dev/null +++ b/Source/Auditing/index.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export { Causation } from './Causation'; +export { CausationType } from './CausationType'; +export type { ICausationManager } from './ICausationManager'; +export { CausationManager } from './CausationManager'; + +import { CausationManager } from './CausationManager'; + +/** + * The default singleton {@link CausationManager} for the process. + * Use this to manage the causation chain for the current async call context. + */ +export const causationManager = new CausationManager(); diff --git a/Source/Correlation/CorrelationId.ts b/Source/Correlation/CorrelationId.ts new file mode 100644 index 0000000..3b1cbe4 --- /dev/null +++ b/Source/Correlation/CorrelationId.ts @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Guid } from '@cratis/fundamentals'; + +/** + * Represents a correlation identifier used to track operations across call boundaries. + */ +export class CorrelationId { + /** + * A well-known {@link CorrelationId} representing an unset/empty value. + */ + static readonly notSet = new CorrelationId('00000000-0000-0000-0000-000000000000'); + + /** + * Creates a new unique {@link CorrelationId}. + * @returns A new {@link CorrelationId} backed by a freshly generated GUID. + */ + static create(): CorrelationId { + return new CorrelationId(Guid.create().toString()); + } + + /** + * Initializes a new instance of the {@link CorrelationId} class. + * @param value - The string value of the correlation identifier. + */ + constructor(readonly value: string) {} + + /** @inheritdoc */ + toString(): string { + return this.value; + } +} diff --git a/Source/Correlation/CorrelationIdManager.ts b/Source/Correlation/CorrelationIdManager.ts new file mode 100644 index 0000000..bad4ba3 --- /dev/null +++ b/Source/Correlation/CorrelationIdManager.ts @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { AsyncLocalStorage } from 'async_hooks'; +import { CorrelationId } from './CorrelationId'; +import { ICorrelationIdAccessor } from './ICorrelationIdAccessor'; +import { ICorrelationIdSetter } from './ICorrelationIdSetter'; + +/** + * Implements both {@link ICorrelationIdAccessor} and {@link ICorrelationIdSetter}, + * using {@link AsyncLocalStorage} to scope the correlation identifier to the active async call context. + */ +export class CorrelationIdManager implements ICorrelationIdAccessor, ICorrelationIdSetter { + private readonly _storage = new AsyncLocalStorage(); + + /** @inheritdoc */ + get current(): CorrelationId { + return this._storage.getStore() ?? CorrelationId.create(); + } + + /** @inheritdoc */ + setCurrent(correlationId: CorrelationId): void { + this._storage.enterWith(correlationId); + } + + /** @inheritdoc */ + clear(): void { + this._storage.enterWith(CorrelationId.create()); + } +} diff --git a/Source/Correlation/ICorrelationIdAccessor.ts b/Source/Correlation/ICorrelationIdAccessor.ts new file mode 100644 index 0000000..7d6db3b --- /dev/null +++ b/Source/Correlation/ICorrelationIdAccessor.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { CorrelationId } from './CorrelationId'; + +/** + * Defines the read side of a correlation identifier provider scoped to the active call context. + */ +export interface ICorrelationIdAccessor { + /** + * Gets the current correlation identifier for the active call context. + * @returns The current {@link CorrelationId}. + */ + readonly current: CorrelationId; +} diff --git a/Source/Correlation/ICorrelationIdSetter.ts b/Source/Correlation/ICorrelationIdSetter.ts new file mode 100644 index 0000000..51f18a9 --- /dev/null +++ b/Source/Correlation/ICorrelationIdSetter.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { CorrelationId } from './CorrelationId'; + +/** + * Defines the write side of a correlation identifier provider scoped to the active call context. + */ +export interface ICorrelationIdSetter { + /** + * Sets the current correlation identifier for the active call context. + * @param correlationId - The {@link CorrelationId} to set. + */ + setCurrent(correlationId: CorrelationId): void; + + /** + * Clears the current correlation identifier for the active call context, + * causing the next access to generate a new unique identifier. + */ + clear(): void; +} diff --git a/Source/Correlation/index.ts b/Source/Correlation/index.ts new file mode 100644 index 0000000..e7f1cd6 --- /dev/null +++ b/Source/Correlation/index.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export { CorrelationId } from './CorrelationId'; +export type { ICorrelationIdAccessor } from './ICorrelationIdAccessor'; +export type { ICorrelationIdSetter } from './ICorrelationIdSetter'; +export { CorrelationIdManager } from './CorrelationIdManager'; + +import { CorrelationIdManager } from './CorrelationIdManager'; + +/** + * The default singleton {@link CorrelationIdManager} for the process. + * Use this to get and set the correlation identifier for the current async call context. + */ +export const correlationIdManager = new CorrelationIdManager(); diff --git a/Source/EventSequences/EventSequence.ts b/Source/EventSequences/EventSequence.ts index 842c55a..a143022 100644 --- a/Source/EventSequences/EventSequence.ts +++ b/Source/EventSequences/EventSequence.ts @@ -16,6 +16,9 @@ import { EventSequenceId } from './EventSequenceId'; import { EventSequenceNumber } from './EventSequenceNumber'; import { ChronicleTracer } from '../Tracing'; import { ChronicleMetrics } from '../Metrics'; +import { identityProvider, Identity } from '../Identity'; +import { causationManager, CausationType } from '../Auditing'; +import { correlationIdManager } from '../Correlation'; /** * Implements {@link IEventSequence} by communicating with the Chronicle Kernel @@ -33,10 +36,14 @@ export class EventSequence implements IEventSequence { async append(eventSourceId: string, event: object, options?: AppendOptions): Promise { const eventType = getEventTypeFor(event.constructor as Function); const correlationId = options?.correlationId === undefined - ? Guid.create() + ? Guid.as(correlationIdManager.current.value) : Guid.as(options.correlationId); const content = JSON.stringify(event); + causationManager.add(CausationType.appendEvent, { eventType: eventType.id.value }); + const causationChain = causationManager.getCurrentChain(); + const identity = identityProvider.getCurrent(); + const metricAttributes = { 'chronicle.event_store': this._eventStoreName, 'chronicle.namespace': this._namespace, @@ -68,17 +75,12 @@ export class EventSequence implements IEventSequence { Tombstone: eventType.tombstone }, Content: content, - Causation: [{ - Occurred: { Value: new Date().toISOString() }, - Type: 'TypeScriptClient.Append', - Properties: {} - }], - CausedBy: { - Subject: '5d032c92-9d5e-41eb-947a-ee5314ed0032', - Name: '[System]', - UserName: '[System]', - OnBehalfOf: undefined - }, + Causation: causationChain.map(c => ({ + Occurred: { Value: c.occurred.toISOString() }, + Type: c.type.name, + Properties: { ...c.properties } + })), + CausedBy: toContractsCausedBy(identity), ConcurrencyScope: { // ulong.MaxValue sent as BigInt so the server recognises it as ConcurrencyScope.None (no validation) SequenceNumber: 18446744073709551615n as unknown as number, @@ -137,9 +139,13 @@ export class EventSequence implements IEventSequence { /** @inheritdoc */ async appendMany(eventSourceId: string, events: object[], options?: AppendOptions): Promise { const correlationId = options?.correlationId === undefined - ? Guid.create() + ? Guid.as(correlationIdManager.current.value) : Guid.as(options.correlationId); + causationManager.add(CausationType.appendManyEvents, { count: String(events.length) }); + const batchCausationChain = causationManager.getCurrentChain(); + const identity = identityProvider.getCurrent(); + const eventsToAppend = events.map(event => { const eventType = getEventTypeFor(event.constructor as Function); return { @@ -153,17 +159,12 @@ export class EventSequence implements IEventSequence { Tombstone: eventType.tombstone }, Content: JSON.stringify(event), - Causation: [{ - Occurred: { Value: new Date().toISOString() }, - Type: 'TypeScriptClient.AppendMany.Event', - Properties: {} - }], - CausedBy: { - Subject: '5d032c92-9d5e-41eb-947a-ee5314ed0032', - Name: '[System]', - UserName: '[System]', - OnBehalfOf: undefined - }, + Causation: batchCausationChain.map(c => ({ + Occurred: { Value: c.occurred.toISOString() }, + Type: c.type.name, + Properties: { ...c.properties } + })), + CausedBy: toContractsCausedBy(identity), ConcurrencyScope: { SequenceNumber: 18446744073709551615n as unknown as number, EventSourceId: false, @@ -199,17 +200,12 @@ export class EventSequence implements IEventSequence { EventSequenceId: this.id.value, CorrelationId: toContractsGuid(correlationId), Events: eventsToAppend, - Causation: [{ - Occurred: { Value: new Date().toISOString() }, - Type: 'TypeScriptClient.AppendMany.Batch', - Properties: {} - }], - CausedBy: { - Subject: '5d032c92-9d5e-41eb-947a-ee5314ed0032', - Name: '[System]', - UserName: '[System]', - OnBehalfOf: undefined - }, + Causation: batchCausationChain.map(c => ({ + Occurred: { Value: c.occurred.toISOString() }, + Type: c.type.name, + Properties: { ...c.properties } + })), + CausedBy: toContractsCausedBy(identity), ConcurrencyScopes: {} }); @@ -370,3 +366,21 @@ function toContractsGuid(guid: Guid): ContractsGuid { }; } +/** + * Converts an {@link Identity} into the CausedBy shape used by Chronicle contracts. + * @param identity - The identity to convert. + * @returns The contracts CausedBy object. + */ +function toContractsCausedBy(identity: Identity): object { + const result: Record = { + Subject: identity.subject, + Name: identity.name, + UserName: identity.userName, + OnBehalfOf: undefined + }; + if (identity.onBehalfOf !== undefined) { + result.OnBehalfOf = toContractsCausedBy(identity.onBehalfOf); + } + return result; +} + diff --git a/Source/Identity/IIdentityProvider.ts b/Source/Identity/IIdentityProvider.ts new file mode 100644 index 0000000..d954de7 --- /dev/null +++ b/Source/Identity/IIdentityProvider.ts @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Identity } from './Identity'; + +/** + * Defines a system that can provide and manage the current {@link Identity} for the active call context. + */ +export interface IIdentityProvider { + /** + * Gets the current identity for the active call context. + * @returns The current {@link Identity}, or {@link Identity.system} if none has been set. + */ + getCurrent(): Identity; + + /** + * Sets the current identity for the active call context. + * @param identity - The {@link Identity} to set. + */ + setCurrentIdentity(identity: Identity): void; + + /** + * Clears the current identity for the active call context, resetting it to {@link Identity.system}. + */ + clearCurrentIdentity(): void; +} diff --git a/Source/Identity/Identity.ts b/Source/Identity/Identity.ts new file mode 100644 index 0000000..74cfc1a --- /dev/null +++ b/Source/Identity/Identity.ts @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Represents an identity of something that is responsible for causing a state change. + * An identity can be a user, a system, a service or anything else that can be identified. + */ +export class Identity { + /** + * The identity used when not set. + */ + static readonly notSet = new Identity('1efc9b81-0612-4466-962c-86acc4e9a028', '[Not Set]', '[Not Set]'); + + /** + * The identity used when the identity is not known. + */ + static readonly unknown = new Identity('3321cf62-db16-425e-8173-99fcfefe11dd', '[Unknown]', '[Unknown]'); + + /** + * The identity used when the system is the cause. + */ + static readonly system = new Identity('5d032c92-9d5e-41eb-947a-ee5314ed0032', '[System]', '[System]'); + + /** + * Initializes a new instance of the {@link Identity} class. + * @param subject - The identifier of the identity, referred to as subject. + * @param name - Name of the identity. + * @param userName - Optional username, defaults to empty string. + * @param onBehalfOf - Optional behalf of {@link Identity}. + */ + constructor( + readonly subject: string, + readonly name: string, + readonly userName: string = '', + readonly onBehalfOf?: Identity + ) {} + + /** + * Returns a new {@link Identity} chain with duplicate subjects removed. + * The first occurrence of each subject is kept. + * @returns A new {@link Identity} with duplicates removed from the chain. + */ + withoutDuplicates(): Identity { + const seen = new Set(); + const chain: Identity[] = []; + let current: Identity | undefined = this; + while (current !== undefined) { + if (!seen.has(current.subject)) { + seen.add(current.subject); + chain.push(current); + } + current = current.onBehalfOf; + } + + let result: Identity | undefined; + for (let i = chain.length - 1; i >= 0; i--) { + result = new Identity(chain[i].subject, chain[i].name, chain[i].userName, result); + } + return result!; + } +} diff --git a/Source/Identity/IdentityProvider.ts b/Source/Identity/IdentityProvider.ts new file mode 100644 index 0000000..27a6a1e --- /dev/null +++ b/Source/Identity/IdentityProvider.ts @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { AsyncLocalStorage } from 'async_hooks'; +import { Identity } from './Identity'; +import { IIdentityProvider } from './IIdentityProvider'; + +/** + * Implements {@link IIdentityProvider} using {@link AsyncLocalStorage} to scope the identity to the active async call context. + */ +export class IdentityProvider implements IIdentityProvider { + private readonly _storage = new AsyncLocalStorage(); + + /** @inheritdoc */ + getCurrent(): Identity { + return this._storage.getStore() ?? Identity.system; + } + + /** @inheritdoc */ + setCurrentIdentity(identity: Identity): void { + this._storage.enterWith(identity); + } + + /** @inheritdoc */ + clearCurrentIdentity(): void { + this._storage.enterWith(Identity.system); + } +} diff --git a/Source/Identity/index.ts b/Source/Identity/index.ts new file mode 100644 index 0000000..af1f897 --- /dev/null +++ b/Source/Identity/index.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export { Identity } from './Identity'; +export type { IIdentityProvider } from './IIdentityProvider'; +export { IdentityProvider } from './IdentityProvider'; + +import { IdentityProvider } from './IdentityProvider'; + +/** + * The default singleton {@link IdentityProvider} for the process. + * Use this to get and set the identity for the current async call context. + */ +export const identityProvider = new IdentityProvider(); diff --git a/Source/index.ts b/Source/index.ts index 522b46f..2986623 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -22,3 +22,6 @@ export * from './Observation'; export * from './Schemas'; export * from './types'; export * from './artifacts'; +export * from './Identity'; +export * from './Auditing'; +export * from './Correlation';