@cratis/chronicle 6.32.1 → 6.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/ChronicleClient.ts +35 -33
  2. package/ChronicleOptions.ts +27 -1
  3. package/EventStore.ts +14 -12
  4. package/Metrics.ts +70 -96
  5. package/Tracing.ts +4 -2
  6. package/WellKnownTelemetryNames.ts +67 -0
  7. package/artifacts/validateArtifactSchemas.spec.ts +1 -1
  8. package/artifacts/withActivatedArtifact.ts +7 -5
  9. package/connection/ChronicleConnection.ts +18 -6
  10. package/connection/CompatibilityPreflight.ts +2 -1
  11. package/connection/KernelKeepAlive.ts +13 -7
  12. package/connection/TokenProvider.ts +3 -1
  13. package/connection/TraceContextMiddleware.ts +30 -0
  14. package/connection/for_ChronicleConnection/when_creating_clients/with_correlated_multi_host_callers.spec.ts +73 -0
  15. package/connection/for_OAuthTokenProvider/when_acquiring_tokens/with_http_instrumentation.spec.ts +102 -0
  16. package/connection/for_TraceContextMiddleware/when_calling/with_active_context.spec.ts +235 -0
  17. package/connection/for_TraceContextMiddleware/when_calling/with_grpc_instrumentation.spec.ts +102 -0
  18. package/correlation/CorrelationIdManager.ts +10 -0
  19. package/dist/ChronicleClient.d.ts.map +1 -1
  20. package/dist/ChronicleClient.js +35 -33
  21. package/dist/ChronicleClient.js.map +1 -1
  22. package/dist/ChronicleOptions.d.ts +8 -0
  23. package/dist/ChronicleOptions.d.ts.map +1 -1
  24. package/dist/ChronicleOptions.js +19 -1
  25. package/dist/ChronicleOptions.js.map +1 -1
  26. package/dist/EventStore.d.ts +3 -1
  27. package/dist/EventStore.d.ts.map +1 -1
  28. package/dist/EventStore.js +14 -12
  29. package/dist/EventStore.js.map +1 -1
  30. package/dist/Metrics.d.ts +23 -64
  31. package/dist/Metrics.d.ts.map +1 -1
  32. package/dist/Metrics.js +62 -92
  33. package/dist/Metrics.js.map +1 -1
  34. package/dist/Tracing.d.ts +1 -1
  35. package/dist/Tracing.d.ts.map +1 -1
  36. package/dist/Tracing.js +4 -2
  37. package/dist/Tracing.js.map +1 -1
  38. package/dist/WellKnownTelemetryNames.d.ts +65 -0
  39. package/dist/WellKnownTelemetryNames.d.ts.map +1 -0
  40. package/dist/WellKnownTelemetryNames.js +67 -0
  41. package/dist/WellKnownTelemetryNames.js.map +1 -0
  42. package/dist/artifacts/withActivatedArtifact.d.ts +2 -1
  43. package/dist/artifacts/withActivatedArtifact.d.ts.map +1 -1
  44. package/dist/artifacts/withActivatedArtifact.js +5 -4
  45. package/dist/artifacts/withActivatedArtifact.js.map +1 -1
  46. package/dist/connection/ChronicleConnection.d.ts +5 -0
  47. package/dist/connection/ChronicleConnection.d.ts.map +1 -1
  48. package/dist/connection/ChronicleConnection.js +13 -6
  49. package/dist/connection/ChronicleConnection.js.map +1 -1
  50. package/dist/connection/CompatibilityPreflight.d.ts.map +1 -1
  51. package/dist/connection/CompatibilityPreflight.js +2 -1
  52. package/dist/connection/CompatibilityPreflight.js.map +1 -1
  53. package/dist/connection/KernelKeepAlive.d.ts +3 -1
  54. package/dist/connection/KernelKeepAlive.d.ts.map +1 -1
  55. package/dist/connection/KernelKeepAlive.js +10 -7
  56. package/dist/connection/KernelKeepAlive.js.map +1 -1
  57. package/dist/connection/TokenProvider.d.ts.map +1 -1
  58. package/dist/connection/TokenProvider.js +3 -1
  59. package/dist/connection/TokenProvider.js.map +1 -1
  60. package/dist/connection/TraceContextMiddleware.d.ts +4 -0
  61. package/dist/connection/TraceContextMiddleware.d.ts.map +1 -0
  62. package/dist/connection/TraceContextMiddleware.js +30 -0
  63. package/dist/connection/TraceContextMiddleware.js.map +1 -0
  64. package/dist/correlation/CorrelationIdManager.d.ts +4 -0
  65. package/dist/correlation/CorrelationIdManager.d.ts.map +1 -1
  66. package/dist/correlation/CorrelationIdManager.js +8 -0
  67. package/dist/correlation/CorrelationIdManager.js.map +1 -1
  68. package/dist/eventSequences/EventLog.d.ts +2 -1
  69. package/dist/eventSequences/EventLog.d.ts.map +1 -1
  70. package/dist/eventSequences/EventLog.js +2 -2
  71. package/dist/eventSequences/EventLog.js.map +1 -1
  72. package/dist/eventSequences/EventSequence.d.ts +4 -1
  73. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  74. package/dist/eventSequences/EventSequence.js +63 -80
  75. package/dist/eventSequences/EventSequence.js.map +1 -1
  76. package/dist/eventStoreSubscriptions/EventStoreSubscriptions.d.ts.map +1 -1
  77. package/dist/eventStoreSubscriptions/EventStoreSubscriptions.js +3 -4
  78. package/dist/eventStoreSubscriptions/EventStoreSubscriptions.js.map +1 -1
  79. package/dist/index.d.ts +7 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +4 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/logging/ChronicleLogEntry.d.ts +11 -0
  84. package/dist/logging/ChronicleLogEntry.d.ts.map +1 -0
  85. package/dist/logging/ChronicleLogEntry.js +4 -0
  86. package/dist/logging/ChronicleLogEntry.js.map +1 -0
  87. package/dist/logging/ChronicleLogLevel.d.ts +9 -0
  88. package/dist/logging/ChronicleLogLevel.d.ts.map +1 -0
  89. package/dist/logging/ChronicleLogLevel.js +12 -0
  90. package/dist/logging/ChronicleLogLevel.js.map +1 -0
  91. package/dist/logging/DiagChronicleLogger.d.ts +7 -0
  92. package/dist/logging/DiagChronicleLogger.d.ts.map +1 -0
  93. package/dist/logging/DiagChronicleLogger.js +10 -0
  94. package/dist/logging/DiagChronicleLogger.js.map +1 -0
  95. package/dist/logging/IChronicleLogger.d.ts +7 -0
  96. package/dist/logging/IChronicleLogger.d.ts.map +1 -0
  97. package/dist/logging/IChronicleLogger.js +4 -0
  98. package/dist/logging/IChronicleLogger.js.map +1 -0
  99. package/dist/logging/createLogger.d.ts +10 -0
  100. package/dist/logging/createLogger.d.ts.map +1 -0
  101. package/dist/logging/createLogger.js +55 -0
  102. package/dist/logging/createLogger.js.map +1 -0
  103. package/dist/projections/Projections.d.ts.map +1 -1
  104. package/dist/projections/Projections.js +4 -3
  105. package/dist/projections/Projections.js.map +1 -1
  106. package/dist/reactors/Reactors.d.ts.map +1 -1
  107. package/dist/reactors/Reactors.js +26 -24
  108. package/dist/reactors/Reactors.js.map +1 -1
  109. package/dist/readModels/IReadModels.d.ts +14 -0
  110. package/dist/readModels/IReadModels.d.ts.map +1 -1
  111. package/dist/readModels/ReadModels.d.ts +6 -0
  112. package/dist/readModels/ReadModels.d.ts.map +1 -1
  113. package/dist/readModels/ReadModels.js +110 -0
  114. package/dist/readModels/ReadModels.js.map +1 -1
  115. package/dist/readModels/for_ReadModels/given/a_stored_read_model.d.ts +20 -0
  116. package/dist/readModels/for_ReadModels/given/a_stored_read_model.d.ts.map +1 -0
  117. package/dist/readModels/for_ReadModels/given/a_stored_read_model.js +79 -0
  118. package/dist/readModels/for_ReadModels/given/a_stored_read_model.js.map +1 -0
  119. package/dist/reducers/Reducers.d.ts.map +1 -1
  120. package/dist/reducers/Reducers.js +28 -25
  121. package/dist/reducers/Reducers.js.map +1 -1
  122. package/dist/seeding/EventSeeding.d.ts.map +1 -1
  123. package/dist/seeding/EventSeeding.js +4 -3
  124. package/dist/seeding/EventSeeding.js.map +1 -1
  125. package/dist/telemetry/ChronicleTelemetryOptions.d.ts +11 -0
  126. package/dist/telemetry/ChronicleTelemetryOptions.d.ts.map +1 -0
  127. package/dist/telemetry/ChronicleTelemetryOptions.js +4 -0
  128. package/dist/telemetry/ChronicleTelemetryOptions.js.map +1 -0
  129. package/dist/telemetry/observeOperation.d.ts +6 -0
  130. package/dist/telemetry/observeOperation.d.ts.map +1 -0
  131. package/dist/telemetry/observeOperation.js +23 -0
  132. package/dist/telemetry/observeOperation.js.map +1 -0
  133. package/dist/telemetry/runInBackgroundContext.d.ts +3 -0
  134. package/dist/telemetry/runInBackgroundContext.d.ts.map +1 -0
  135. package/dist/telemetry/runInBackgroundContext.js +9 -0
  136. package/dist/telemetry/runInBackgroundContext.js.map +1 -0
  137. package/dist/telemetry/spanAttributes.d.ts +14 -0
  138. package/dist/telemetry/spanAttributes.d.ts.map +1 -0
  139. package/dist/telemetry/spanAttributes.js +47 -0
  140. package/dist/telemetry/spanAttributes.js.map +1 -0
  141. package/dist/tsconfig.tsbuildinfo +1 -1
  142. package/eventSequences/EventLog.ts +4 -2
  143. package/eventSequences/EventSequence.ts +64 -81
  144. package/eventStoreSubscriptions/EventStoreSubscriptions.ts +5 -5
  145. package/for_ChronicleClient/when_starting_background_work/with_a_correlated_caller.spec.ts +150 -0
  146. package/for_ChronicleOptions/when_configuring_telemetry/with_event_source_identifiers.spec.ts +44 -0
  147. package/index.ts +7 -1
  148. package/logging/ChronicleLogEntry.ts +14 -0
  149. package/logging/ChronicleLogLevel.ts +11 -0
  150. package/logging/DiagChronicleLogger.ts +13 -0
  151. package/logging/IChronicleLogger.ts +10 -0
  152. package/logging/createLogger.ts +50 -0
  153. package/logging/for_ChronicleLogger/when_logging/with_client_owned_sinks.spec.ts +96 -0
  154. package/package.json +7 -1
  155. package/projections/Projections.ts +4 -3
  156. package/reactors/Reactors.ts +27 -25
  157. package/reactors/for_Reactors/when_delivering/with_activation.spec.ts +14 -14
  158. package/readModels/IReadModels.ts +15 -0
  159. package/readModels/ReadModels.ts +118 -1
  160. package/readModels/for_ReadModels/given/a_stored_read_model.ts +62 -0
  161. package/readModels/for_ReadModels/when_releasing_a_document/with_a_default_subject.spec.ts +39 -0
  162. package/readModels/for_ReadModels/when_releasing_a_document/with_a_frozen_document.spec.ts +28 -0
  163. package/readModels/for_ReadModels/when_releasing_a_document/with_a_malformed_response.spec.ts +24 -0
  164. package/readModels/for_ReadModels/when_releasing_a_document/with_a_reported_failure.spec.ts +38 -0
  165. package/readModels/for_ReadModels/when_releasing_a_document/with_an_empty_released_value.spec.ts +19 -0
  166. package/readModels/for_ReadModels/when_releasing_a_document/with_an_unknown_subject_property.spec.ts +42 -0
  167. package/readModels/for_ReadModels/when_releasing_a_document/with_bookkeeping_fields.spec.ts +27 -0
  168. package/readModels/for_ReadModels/when_releasing_a_document/with_conflicting_or_missing_properties.spec.ts +32 -0
  169. package/readModels/for_ReadModels/when_releasing_a_document/with_empty_subject_metadata.spec.ts +56 -0
  170. package/readModels/for_ReadModels/when_releasing_a_document/with_invalid_subject_metadata.spec.ts +27 -0
  171. package/readModels/for_ReadModels/when_releasing_a_document/with_mixed_subjects.spec.ts +38 -0
  172. package/readModels/for_ReadModels/when_releasing_a_document/with_property_subjects.spec.ts +52 -0
  173. package/readModels/for_ReadModels/when_releasing_a_document/with_whitespace_subjects.spec.ts +24 -0
  174. package/readModels/for_ReadModels/when_releasing_a_document/without_subjects.spec.ts +24 -0
  175. package/reducers/Reducers.ts +29 -26
  176. package/seeding/EventSeeding.ts +6 -4
  177. package/telemetry/ChronicleTelemetryOptions.ts +8 -0
  178. package/telemetry/for_ChronicleTelemetry/given/a_telemetry_session.fixture.ts +36 -0
  179. package/telemetry/for_ChronicleTelemetry/given/an_event_sequence.fixture.ts +29 -0
  180. package/telemetry/for_ChronicleTelemetry/when_appending/with_shared_and_legacy_attributes.spec.ts +58 -0
  181. package/telemetry/for_ChronicleTelemetry/when_appending/without_an_sdk.spec.ts +15 -0
  182. package/telemetry/for_ChronicleTelemetry/when_documenting/with_public_names.spec.ts +15 -0
  183. package/telemetry/for_ChronicleTelemetry/when_recording_failures/with_sensitive_exception_details.spec.ts +34 -0
  184. package/telemetry/for_ChronicleTelemetry/when_recording_identifiers/with_per_client_privacy.spec.ts +92 -0
  185. package/telemetry/for_ChronicleTelemetry/when_recording_metrics/with_legacy_overlap.spec.ts +40 -0
  186. package/telemetry/for_ChronicleTelemetry/when_recording_sequence_numbers/with_integer_precision.spec.ts +23 -0
  187. package/telemetry/observeOperation.ts +25 -0
  188. package/telemetry/runInBackgroundContext.ts +10 -0
  189. package/telemetry/spanAttributes.ts +49 -0
@@ -0,0 +1,50 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { context, propagation, trace, isSpanContextValid, type Attributes } from '@opentelemetry/api';
5
+ import { ClientError, ServerError } from 'nice-grpc-common';
6
+ import { correlationIdManager } from '../correlation/index.js';
7
+ import { WellKnownTelemetryNames } from '../WellKnownTelemetryNames.js';
8
+ import { exceptionType } from '../telemetry/spanAttributes.js';
9
+ import { ChronicleLogLevel } from './ChronicleLogLevel.js';
10
+ import { DiagChronicleLogger } from './DiagChronicleLogger.js';
11
+ import type { IChronicleLogger } from './IChronicleLogger.js';
12
+
13
+ /** Binds a category to a client-owned sink and adds safe correlation fields. */
14
+ export function createLogger(category: string, sink: IChronicleLogger = new DiagChronicleLogger()) {
15
+ const log = (level: ChronicleLogLevel) => (message: string, fields: Record<string, unknown> = {}) => {
16
+ const attributes: Attributes = {};
17
+ for (const [key, value] of Object.entries(fields)) {
18
+ if (key === 'error') {
19
+ attributes['error.type'] = exceptionType(value);
20
+ attributes['exception.type'] = exceptionType(value);
21
+ if (value instanceof ClientError || value instanceof ServerError) attributes['rpc.grpc.status_code'] = value.code;
22
+ } else if (key === 'sequenceNumber') {
23
+ const number = Number(value);
24
+ if (Number.isSafeInteger(number)) attributes[WellKnownTelemetryNames.attributes.sequenceNumber] = number;
25
+ } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
26
+ attributes[key] = value;
27
+ } else if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
28
+ attributes[key] = value;
29
+ }
30
+ }
31
+ const active = context.active();
32
+ const correlationKey = WellKnownTelemetryNames.attributes.correlationId;
33
+ const correlation = correlationIdManager.scoped?.toString() ?? propagation.getBaggage(active)?.getEntry(correlationKey)?.value;
34
+ if (correlation) attributes[correlationKey] = correlation;
35
+ const spanContext = trace.getSpanContext(active);
36
+ if (spanContext && isSpanContextValid(spanContext)) {
37
+ attributes.trace_id = spanContext.traceId;
38
+ attributes.span_id = spanContext.spanId;
39
+ }
40
+ try {
41
+ sink.log({ category, level, message, attributes });
42
+ } catch {
43
+ // Diagnostics are not the operation's outcome. A broken sink must not change RPCs or acknowledgements.
44
+ }
45
+ };
46
+ return {
47
+ verbose: log(ChronicleLogLevel.Verbose), debug: log(ChronicleLogLevel.Debug),
48
+ info: log(ChronicleLogLevel.Info), warn: log(ChronicleLogLevel.Warn), error: log(ChronicleLogLevel.Error)
49
+ };
50
+ }
@@ -0,0 +1,96 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { beforeEach, chai, describe, it, vi } from 'vitest';
5
+ import { diag, DiagLogLevel } from '@opentelemetry/api';
6
+ import { ClientError, ServerError, Status } from 'nice-grpc-common';
7
+ import { ChronicleClient } from '../../../ChronicleClient.js';
8
+ import { ChronicleOptions } from '../../../ChronicleOptions.js';
9
+ import { createLogger } from '../../createLogger.js';
10
+ import type { ChronicleLogEntry } from '../../ChronicleLogEntry.js';
11
+ import { telemetrySession } from '../../../telemetry/for_ChronicleTelemetry/given/a_telemetry_session.fixture.js';
12
+ import { CorrelationId, correlationIdManager } from '../../../correlation/index.js';
13
+
14
+ vi.mock('../../../connection/ChronicleConnection.js', () => ({ ChronicleConnection: class { disconnect() {} } }));
15
+ chai.should();
16
+ const telemetry = telemetrySession();
17
+
18
+ describe('when two clients have different diagnostic sinks', () => {
19
+ const first: ChronicleLogEntry[] = [];
20
+ const second: ChronicleLogEntry[] = [];
21
+ beforeEach(() => {
22
+ first.length = 0;
23
+ second.length = 0;
24
+ const one = new ChronicleClient(ChronicleOptions.development({ discoveryPatterns: [], logger: { log: entry => first.push(entry) } }));
25
+ const two = new ChronicleClient(ChronicleOptions.development({ discoveryPatterns: [], logger: { log: entry => second.push(entry) } }));
26
+ one.dispose();
27
+ two.dispose();
28
+ });
29
+ it('should route each diagnostic only to its owning sink', () => {
30
+ first.should.have.lengthOf(2);
31
+ second.should.have.lengthOf(2);
32
+ (first[0] === second[0]).should.be.false;
33
+ });
34
+ });
35
+
36
+ describe('when logging in a correlated trace', () => {
37
+ let entry: ChronicleLogEntry;
38
+ beforeEach(async () => {
39
+ const logger = createLogger('test', { log: value => { entry = value; } });
40
+ await correlationIdManager.run(new CorrelationId('business-id'), () => telemetry.traces.getTracer('host').startActiveSpan('operation', async span => {
41
+ try { logger.error('Operation failed', { error: new TypeError('secret'), sequenceNumber: 9007199254740992n }); }
42
+ finally { span.end(); }
43
+ }));
44
+ });
45
+ it('should export error types with business and trace identifiers but no exception details', () => {
46
+ entry.attributes.should.include({ 'error.type': 'TypeError', 'exception.type': 'TypeError', 'cratis.correlation_id': 'business-id' });
47
+ entry.attributes.should.have.property('trace_id');
48
+ entry.attributes.should.have.property('span_id');
49
+ entry.attributes.should.not.have.property('cratis.event_sequence.number');
50
+ JSON.stringify(entry).should.not.contain('secret');
51
+ });
52
+ });
53
+
54
+ for (const error of [new ClientError('/test', Status.UNAVAILABLE, 'secret'), new ServerError(Status.UNAVAILABLE, 'secret')]) {
55
+ describe('when logging a gRPC failure', () => {
56
+ let entry: ChronicleLogEntry;
57
+ beforeEach(() => {
58
+ createLogger('test', { log: value => { entry = value; } }).error('Operation failed', { error });
59
+ });
60
+ it('should retain the numeric status without exporting sensitive exception details', () => {
61
+ entry.attributes.should.include({ 'rpc.grpc.status_code': Status.UNAVAILABLE });
62
+ JSON.stringify(entry).should.not.contain('secret');
63
+ });
64
+ });
65
+ }
66
+
67
+ for (const error of [new DOMException('secret', 'TimeoutError'), new DOMException('secret', 'AbortError'), { code: Status.UNAVAILABLE, message: 'secret' }]) {
68
+ describe('when logging a non-gRPC error with a numeric code', () => {
69
+ let entry: ChronicleLogEntry;
70
+ beforeEach(() => {
71
+ createLogger('test', { log: value => { entry = value; } }).error('Operation failed', { error });
72
+ });
73
+ it('should not label its code as a gRPC status', () => {
74
+ entry.attributes.should.not.have.property('rpc.grpc.status_code');
75
+ JSON.stringify(entry).should.not.contain('secret');
76
+ });
77
+ });
78
+ }
79
+
80
+ describe('when no logger is configured during the compatibility period', () => {
81
+ it('should still deliver sanitized diagnostics to diag', () => {
82
+ const error = vi.fn();
83
+ diag.setLogger({ error, warn: vi.fn(), info: vi.fn(), debug: vi.fn(), verbose: vi.fn() }, DiagLogLevel.ERROR);
84
+ try {
85
+ createLogger('test').error('Operation failed', { error: new Error('secret') });
86
+ error.mock.calls.should.have.lengthOf(1);
87
+ JSON.stringify(error.mock.calls).should.not.contain('secret');
88
+ } finally { diag.disable(); }
89
+ });
90
+ });
91
+
92
+ describe('when a diagnostic sink throws', () => {
93
+ it('should leave the operation unaffected', () => {
94
+ (() => createLogger('test', { log: () => { throw new Error('sink failed'); } }).info('Diagnostic')).should.not.throw();
95
+ });
96
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/chronicle",
3
- "version": "6.32.1",
3
+ "version": "6.34.0",
4
4
  "description": "Event sourcing for TypeScript and Node.js — the idiomatic client for Cratis Chronicle, the open-source event-sourcing database",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -184,6 +184,12 @@
184
184
  },
185
185
  "devDependencies": {
186
186
  "@cratis/fundamentals": "7.20.0",
187
+ "@opentelemetry/context-async-hooks": "2.11.0",
188
+ "@opentelemetry/core": "2.11.0",
189
+ "@opentelemetry/instrumentation-grpc": "0.222.0",
190
+ "@opentelemetry/instrumentation-http": "0.222.0",
191
+ "@opentelemetry/sdk-metrics": "2.11.0",
192
+ "@opentelemetry/sdk-trace-base": "2.11.0",
187
193
  "@types/node": "^26.6.3",
188
194
  "@typescript/native": "npm:typescript@^7.0.2",
189
195
  "esbuild": "^0.28.2",
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
- import { diag } from '@opentelemetry/api';
4
+ import { createLogger } from '../logging/createLogger.js';
5
5
  import { ProjectionOwner, type ProjectionDefinition } from '@cratis/chronicle.contracts';
6
6
  import { Constructor, Guid } from '@cratis/fundamentals';
7
7
  import { IClientArtifactsProvider } from '../artifacts/index.js';
@@ -36,7 +36,7 @@ export class Projections implements IProjections {
36
36
  private readonly _modelBound = new Map<string, Constructor>();
37
37
  private readonly _failedPartitions: FailedPartitions;
38
38
 
39
- private readonly _logger = diag.createComponentLogger({ namespace: '@cratis/chronicle/projections' });
39
+ private readonly _logger: ReturnType<typeof createLogger>;
40
40
 
41
41
  /**
42
42
  * Creates a new {@link Projections} instance.
@@ -55,6 +55,7 @@ export class Projections implements IProjections {
55
55
  private readonly _defaultSinkTypeId: string,
56
56
  private readonly _readModelNamingPolicy?: ReadModelNamingPolicy
57
57
  ) {
58
+ this._logger = createLogger('@cratis/chronicle/projections', _connection.logger);
58
59
  this._failedPartitions = new FailedPartitions(_eventStore, _namespace, _connection);
59
60
  }
60
61
 
@@ -296,7 +297,7 @@ export class Projections implements IProjections {
296
297
  identifier,
297
298
  attempt,
298
299
  nextAttemptInMs: delay,
299
- error: msg
300
+ error
300
301
  });
301
302
  await new Promise(resolve => setTimeout(resolve, delay));
302
303
  delay = Math.min(delay * 2, 15000);
@@ -2,7 +2,8 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import 'reflect-metadata';
5
- import { diag } from '@opentelemetry/api';
5
+ import { createLogger } from '../logging/createLogger.js';
6
+ import { runInBackgroundContext } from '../telemetry/runInBackgroundContext.js';
6
7
  import { Constructor } from '@cratis/fundamentals';
7
8
  import { ObservationState, ReactorMessage, ReplayState } from '@cratis/chronicle.contracts';
8
9
  import { IClientArtifactsProvider } from '../artifacts/index.js';
@@ -102,7 +103,7 @@ export class Reactors implements IReactors {
102
103
  /** How long to wait before re-establishing an observation whose stream ended. */
103
104
  private static readonly _reobserveDelayMs = 2000;
104
105
 
105
- private readonly _logger = diag.createComponentLogger({ namespace: '@cratis/chronicle/reactors' });
106
+ private readonly _logger: ReturnType<typeof createLogger>;
106
107
  private readonly _lifecycle: ConnectionLifecycle;
107
108
  private readonly _reactors = new Map<string, Constructor>();
108
109
  private readonly _queues = new Map<string, AsyncQueue<ReactorMessage>>();
@@ -130,6 +131,7 @@ export class Reactors implements IReactors {
130
131
  private readonly _eventStore?: IEventStore,
131
132
  private readonly _artifactActivator?: ClientArtifactsActivator
132
133
  ) {
134
+ this._logger = createLogger('@cratis/chronicle/reactors', _connection.logger);
133
135
  this._lifecycle = lifecycle;
134
136
  lifecycle.onDisconnected(async () => {
135
137
  this._logger.info('Disconnected — stopping all reactor observations');
@@ -177,19 +179,21 @@ export class Reactors implements IReactors {
177
179
  }
178
180
 
179
181
  private startObservation(id: string, reactorType: Constructor): void {
180
- if (this._disposed) return;
181
- const metadata = getReactorMetadata(reactorType)!;
182
- const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
183
- const eventTypes = getReactorEventTypes(reactorType, this._clientArtifacts.eventTypes);
184
-
185
- this._logger.info('Starting reactor observation', {
186
- reactorId: id,
187
- eventSequenceId,
188
- handlerCount: eventTypes.length,
189
- handlers: eventTypes.map(e => e.methodName)
182
+ runInBackgroundContext(() => {
183
+ if (this._disposed) return;
184
+ const metadata = getReactorMetadata(reactorType)!;
185
+ const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
186
+ const eventTypes = getReactorEventTypes(reactorType, this._clientArtifacts.eventTypes);
187
+
188
+ this._logger.info('Starting reactor observation', {
189
+ reactorId: id,
190
+ eventSequenceId,
191
+ handlerCount: eventTypes.length,
192
+ handlers: eventTypes.map(e => e.methodName)
193
+ });
194
+
195
+ void this.runObservation(id, reactorType, eventSequenceId, eventTypes);
190
196
  });
191
-
192
- void this.runObservation(id, reactorType, eventSequenceId, eventTypes);
193
197
  }
194
198
 
195
199
  private async runObservation(
@@ -201,7 +205,7 @@ export class Reactors implements IReactors {
201
205
  try {
202
206
  await this.observeReactor(id, reactorType, eventSequenceId, eventTypes);
203
207
  } catch (error) {
204
- this._logger.error('Reactor observation loop exited with error', { reactorId: id, error: String(error) });
208
+ this._logger.error('Reactor observation loop exited with error', { reactorId: id, error });
205
209
  }
206
210
 
207
211
  this.scheduleReobserve(id, reactorType);
@@ -223,14 +227,14 @@ export class Reactors implements IReactors {
223
227
  return;
224
228
  }
225
229
 
226
- const handle = setTimeout(() => {
230
+ const handle = runInBackgroundContext(() => setTimeout(() => {
227
231
  if (!this._registered || this._disposed) {
228
232
  return;
229
233
  }
230
234
 
231
235
  this._logger.info('Re-establishing reactor observation', { reactorId: id });
232
236
  this.startObservation(id, reactorType);
233
- }, Reactors._reobserveDelayMs);
237
+ }, Reactors._reobserveDelayMs));
234
238
 
235
239
  handle.unref?.();
236
240
  }
@@ -286,7 +290,6 @@ export class Reactors implements IReactors {
286
290
 
287
291
  this._logger.debug('Received events to observe', {
288
292
  reactorId: id,
289
- partition: eventsToObserve.Partition,
290
293
  count: eventsToObserve.Events.length,
291
294
  replayState: eventsToObserve.ReplayState
292
295
  });
@@ -301,12 +304,12 @@ export class Reactors implements IReactors {
301
304
  replayState: eventsToObserve.ReplayState
302
305
  }, this._artifactActivator, artifact => runActivated(artifact, () =>
303
306
  notifyReplayLifecycle(artifact.instance, eventsToObserve.ReplayState, eventsToObserve.Partition),
304
- { delivery: ArtifactDelivery.ReplayNotification, replayState: eventsToObserve.ReplayState }));
307
+ { delivery: ArtifactDelivery.ReplayNotification, replayState: eventsToObserve.ReplayState }), this._connection.logger);
305
308
  } else if (reactorInstance) {
306
309
  await notifyReplayLifecycle(reactorInstance, eventsToObserve.ReplayState, eventsToObserve.Partition);
307
310
  }
308
311
  } catch (err) {
309
- this._logger.error('Error notifying reactor of replay lifecycle transition', { reactorId: id, error: String(err) });
312
+ this._logger.error('Error notifying reactor of replay lifecycle transition', { reactorId: id, error: err });
310
313
  if (err instanceof ArtifactCompletionFailed && err.processingError !== undefined) {
311
314
  exceptionMessages.push(String(err.processingError));
312
315
  }
@@ -348,7 +351,6 @@ export class Reactors implements IReactors {
348
351
  }
349
352
 
350
353
  const content = JSON.parse(event.Content) as Record<string, unknown>;
351
- this._logger.debug('Event content', { reactorId: id, eventTypeId, contentKeys: Object.keys(content), rawContent: event.Content.substring(0, 200) });
352
354
  const context = toClientEventContext(event.Context!);
353
355
 
354
356
  this._logger.info('Invoking reactor handler', {
@@ -364,7 +366,7 @@ export class Reactors implements IReactors {
364
366
 
365
367
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
366
368
  } catch (err) {
367
- this._logger.error('Error handling event in reactor', { reactorId: id, error: String(err) });
369
+ this._logger.error('Error handling event in reactor', { reactorId: id, error: err });
368
370
  exceptionMessages.push(String(err));
369
371
  exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
370
372
  state = ObservationState.Failed;
@@ -386,9 +388,9 @@ export class Reactors implements IReactors {
386
388
  readModels: services.readModels, eventSequenceId, partition: eventsToObserve.Partition,
387
389
  signal: controller.signal, delivery: ArtifactDelivery.Events,
388
390
  eventContext: toClientEventContext(firstInvocableEvent.Context!)
389
- }, this._artifactActivator, processEvents);
391
+ }, this._artifactActivator, processEvents, this._connection.logger);
390
392
  } catch (err) {
391
- this._logger.error('Error activating reactor', { reactorId: id, error: String(err) });
393
+ this._logger.error('Error activating reactor', { reactorId: id, error: err });
392
394
  exceptionMessages.push(String(err));
393
395
  const completionStack = err instanceof Error ? (err.stack ?? '') : '';
394
396
  exceptionStackTrace = err instanceof ArtifactCompletionFailed && exceptionStackTrace
@@ -419,7 +421,7 @@ export class Reactors implements IReactors {
419
421
  if (!this._queues.has(id)) {
420
422
  this._logger.debug('Reactor observation stream closed cleanly', { reactorId: id });
421
423
  } else {
422
- this._logger.error('Reactor observation stream ended unexpectedly', { reactorId: id, error: String(err) });
424
+ this._logger.error('Reactor observation stream ended unexpectedly', { reactorId: id, error: err });
423
425
  }
424
426
  } finally {
425
427
  // Only retire our own queue: a reconnect can already have replaced it,
@@ -4,7 +4,8 @@
4
4
  import { EventContext as WireContext, EventObservationState, EventType, ObservationState, type ReactorMessage } from '@cratis/chronicle.contracts';
5
5
  import { ReplayState } from '../../../index.js';
6
6
  import { chai, describe, it, vi } from 'vitest';
7
- import { diag, DiagLogLevel, type DiagLogger } from '@opentelemetry/api';
7
+ import type { IChronicleLogger } from '../../../logging/IChronicleLogger.js';
8
+ import type { ChronicleLogEntry } from '../../../logging/ChronicleLogEntry.js';
8
9
  import type { ChronicleConnection } from '../../../connection/index.js';
9
10
  import { ConnectionLifecycle } from '../../../connection/ConnectionLifecycle.js';
10
11
  import type { IClientArtifactsProvider } from '../../../artifacts/IClientArtifactsProvider.js';
@@ -98,7 +99,7 @@ function delivery(events: ReturnType<typeof event>[], replayState = ReplayState.
98
99
  }
99
100
 
100
101
  async function observe(type: Function, batches: ReturnType<typeof delivery>[], namespace = 'tenant-a',
101
- activator?: ClientArtifactsActivator, appendResult = true, onAppendMany?: () => void) {
102
+ activator?: ClientArtifactsActivator, appendResult = true, onAppendMany?: () => void, logger?: IChronicleLogger) {
102
103
  const acknowledgements: NonNullable<ReactorMessage['Content']>['Value1'][] = [];
103
104
  const append = vi.fn().mockResolvedValue({ isSuccess: appendResult });
104
105
  const appendMany = vi.fn().mockImplementation(async () => {
@@ -122,7 +123,7 @@ async function observe(type: Function, batches: ReturnType<typeof delivery>[], n
122
123
  await lifecycle.disconnected(error => { throw error; });
123
124
  finished();
124
125
  }
125
- const connection = { reactors: { observe: stream } } as unknown as ChronicleConnection;
126
+ const connection = { logger, reactors: { observe: stream } } as unknown as ChronicleConnection;
126
127
  const artifacts = { reactors: [type], eventTypes: [ActivationEvent] } as unknown as IClientArtifactsProvider;
127
128
  await new Reactors(artifacts, connection, 'store', namespace, lifecycle, eventLog, undefined, store, activator).register();
128
129
  await complete;
@@ -462,16 +463,15 @@ describe('when delivering reactor batches', () => {
462
463
  });
463
464
 
464
465
  it('should log disposal failure without changing successful acknowledgement', async () => {
465
- const errors: string[] = [];
466
- diag.setLogger({ error: (...values: unknown[]) => { errors.push(values.map(String).join(' ')); } } as DiagLogger, DiagLogLevel.ERROR);
467
- try {
468
- const activator: ClientArtifactsActivator = type => ({ instance: new type(), dispose: async () => { throw new Error('cleanup'); } });
469
- const result = await observe(ActivationReactor, [delivery([event('live', 1n)])], 'tenant-a', activator);
470
- result.acknowledgements[0]?.State.should.equal(ObservationState.Success);
471
- result.acknowledgements[0]?.LastSuccessfulObservation.should.equal(1n);
472
- errors.some(value => value.includes('Error disposing activated artifact')).should.be.true;
473
- } finally {
474
- diag.disable();
475
- }
466
+ const entries: ChronicleLogEntry[] = [];
467
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(), dispose: async () => { throw new Error('private cleanup details'); } });
468
+ const result = await observe(ActivationReactor, [delivery([event('private payload', 1n)])], 'tenant-a', activator,
469
+ true, undefined, { log: entry => entries.push(entry) });
470
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Success);
471
+ result.acknowledgements[0]?.LastSuccessfulObservation.should.equal(1n);
472
+ entries.filter(entry => entry.attributes['exception.type'] === 'Error').should.have.lengthOf(1);
473
+ JSON.stringify(entries).should.not.contain('private cleanup details');
474
+ JSON.stringify(entries).should.not.contain('private payload');
475
+ JSON.stringify(entries).should.not.contain('book-1');
476
476
  });
477
477
  });
@@ -95,4 +95,19 @@ export interface IReadModels {
95
95
  * @returns The read model instances with decrypted PII values.
96
96
  */
97
97
  releaseMany<TReadModel>(readModelType: Constructor<TReadModel>, instances: TReadModel[]): Promise<TReadModel[]>;
98
+
99
+ /**
100
+ * Releases PII in a raw stored document using its __subject and per-property __subjects metadata.
101
+ * Each __subjects key names a top-level property and overrides the default for its entire subtree.
102
+ * Properties without either subject are copied unchanged; id and @subject() are not fallbacks.
103
+ * @param readModelType - The discovered read model type supplying the schema.
104
+ * @param document - A JSON document, with optional non-empty string __subject and a __subjects map of property names to non-empty strings.
105
+ * @returns A new plain document containing only schema-declared top-level properties, without __subject or __subjects.
106
+ * The input is never mutated. This does not construct a typed read model instance.
107
+ * @throws If metadata is invalid, a subject property is absent from the document or schema, any release fails,
108
+ * or a response is malformed, incomplete, or conflicts with another property's group. No partial result is returned.
109
+ * @remarks Kernel per-property decryption failures that return HasError: false cannot be detected:
110
+ * https://github.com/Cratis/Chronicle/issues/4480.
111
+ */
112
+ releaseDocument<TReadModel>(readModelType: Constructor<TReadModel>, document: Readonly<Record<string, unknown>>): Promise<Record<string, unknown>>;
98
113
  }
@@ -19,7 +19,7 @@ import { hasFromEventMetadata } from '../projections/modelBound/fromEvent.js';
19
19
  import { hasModelBoundProperties } from '../types/TypeDiscoverer.js';
20
20
  import { isPassive } from '../projections/modelBound/passive.js';
21
21
  import { getReducerMetadata } from '../reducers/reducer.js';
22
- import { JsonSchemaGenerator } from '../schemas/index.js';
22
+ import { JsonSchemaGenerator, type JsonSchema } from '../schemas/index.js';
23
23
  import { WellKnownSinks } from '../sinks/index.js';
24
24
  import { getReadModelMetadata, getReadModelId } from './readModel.js';
25
25
  import { buildReadModelDefinition } from './buildReadModelDefinition.js';
@@ -238,6 +238,123 @@ export class ReadModels implements IReadModels {
238
238
  return Promise.all(releasePromises);
239
239
  }
240
240
 
241
+ /** @inheritdoc */
242
+ async releaseDocument<TReadModel>(readModelType: Constructor<TReadModel>, document: Readonly<Record<string, unknown>>): Promise<Record<string, unknown>> {
243
+ const readModel = this.resolveReadModel(readModelType);
244
+ const schema = JSON.parse(this.getReadModelSchema(readModelType, readModel.identifier)) as JsonSchema;
245
+ const properties = schema.properties ?? {};
246
+ const stored = this.copyStoredDocument(document);
247
+ const storedDefaultSubject = stored.__subject;
248
+ const defaultSubject = this.isDocumentSubject(storedDefaultSubject) ? storedDefaultSubject : undefined;
249
+ const storedSubjects = this.isDocumentObject(stored.__subjects) ? stored.__subjects : {};
250
+ const subjects = new Map<string, string>();
251
+
252
+ if (storedDefaultSubject !== undefined && storedDefaultSubject !== null && typeof storedDefaultSubject !== 'string') {
253
+ throw new Error('Stored read model document has an invalid default subject.');
254
+ }
255
+ for (const [property, subject] of Object.entries(storedSubjects)) {
256
+ if (property === '__subject' || property === '__subjects') {
257
+ throw new Error('Stored read model document has an unknown subject property.');
258
+ }
259
+ // The kernel's JSON subject map ignores null/empty strings, but rejects non-string values.
260
+ if (subject !== null && typeof subject !== 'string') {
261
+ throw new Error('Stored read model document has an invalid property subject.');
262
+ }
263
+ // Cleared or removed properties can leave stale mappings in stored documents.
264
+ if (!Object.hasOwn(stored, property)) {
265
+ continue;
266
+ }
267
+ if (!Object.hasOwn(properties, property)) {
268
+ throw new Error('Stored read model document has an unknown subject property.');
269
+ }
270
+ if (this.isDocumentSubject(subject)) {
271
+ subjects.set(property, subject);
272
+ }
273
+ }
274
+
275
+ const groups = new Map<string, string[]>();
276
+ const releasedEntries: [string, unknown][] = [];
277
+ for (const [property, value] of Object.entries(stored)) {
278
+ // Only schema-declared data is returned, never storage bookkeeping.
279
+ if (property === '__subject' || property === '__subjects' || !Object.hasOwn(properties, property)) {
280
+ continue;
281
+ }
282
+ const subject = subjects.get(property) ?? defaultSubject;
283
+ if (subject === undefined) {
284
+ releasedEntries.push([property, value]);
285
+ continue;
286
+ }
287
+ const group = groups.get(subject) ?? [];
288
+ group.push(property);
289
+ groups.set(subject, group);
290
+ }
291
+
292
+ for (const [subject, propertyNames] of groups) {
293
+ const payload = Object.fromEntries(propertyNames.map(property => [property, stored[property]]));
294
+ const groupSchema: JsonSchema = {
295
+ ...schema,
296
+ properties: Object.fromEntries(propertyNames.map(property => [property, properties[property]])),
297
+ required: schema.required?.filter(property => propertyNames.includes(property))
298
+ };
299
+ const released = await this.releaseDocumentProperties(subject, groupSchema, payload);
300
+ const releasedProperties = Object.keys(released);
301
+ if (releasedProperties.length !== propertyNames.length ||
302
+ releasedProperties.some(property => !Object.hasOwn(payload, property))) {
303
+ throw new Error('Failed to release stored read model document: missing or conflicting properties.');
304
+ }
305
+ releasedEntries.push(...Object.entries(released));
306
+ }
307
+
308
+ // Publish only after every subject succeeded. Entire top-level subtrees belong to one group.
309
+ return Object.fromEntries(releasedEntries);
310
+ }
311
+
312
+ private async releaseDocumentProperties(subject: string, schema: JsonSchema, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
313
+ try {
314
+ const response = await this._connection.compliance.release({
315
+ EventStore: this._eventStore,
316
+ Namespace: this._namespace,
317
+ Subject: subject,
318
+ Schema: JSON.stringify(schema),
319
+ Payload: JSON.stringify(payload)
320
+ });
321
+ if (response.HasError !== false || typeof response.Payload !== 'string') {
322
+ throw new Error();
323
+ }
324
+ const released: unknown = JSON.parse(response.Payload);
325
+ if (!this.isDocumentObject(released)) {
326
+ throw new Error();
327
+ }
328
+ return released;
329
+ } catch {
330
+ // Kernel, transport and JSON parser errors can contain PII; do not expose them as causes.
331
+ throw new Error('Failed to release PII in stored read model document.');
332
+ }
333
+ }
334
+
335
+ private copyStoredDocument(document: Readonly<Record<string, unknown>>): Record<string, unknown> {
336
+ try {
337
+ if (!this.isDocumentObject(document)) {
338
+ throw new Error();
339
+ }
340
+ const copy: unknown = JSON.parse(JSON.stringify(document));
341
+ if (!this.isDocumentObject(copy)) {
342
+ throw new Error();
343
+ }
344
+ return copy;
345
+ } catch {
346
+ throw new Error('Stored read model document must be a JSON object.');
347
+ }
348
+ }
349
+
350
+ private isDocumentObject(value: unknown): value is Record<string, unknown> {
351
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
352
+ }
353
+
354
+ private isDocumentSubject(value: unknown): value is string {
355
+ return typeof value === 'string' && value.length > 0;
356
+ }
357
+
241
358
  private async releaseSnapshotInstances<TReadModel>(readModelType: Constructor<TReadModel>, snapshots: ReadModelSnapshot<TReadModel>[]): Promise<ReadModelSnapshot<TReadModel>[]> {
242
359
  return Promise.all(
243
360
  snapshots.map(async snapshot => ({
@@ -0,0 +1,62 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { field } from '@cratis/fundamentals';
6
+ import type { ReleaseRequest, ReleaseResponse } from '@cratis/chronicle.contracts';
7
+ import { vi } from 'vitest';
8
+ import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
9
+ import type { ChronicleConnection } from '../../../connection/index.js';
10
+ import { pii } from '../../../compliance/pii.js';
11
+ import { subject } from '../../../compliance/subject.js';
12
+ import { fromEvent } from '../../../projections/modelBound/fromEvent.js';
13
+ import type { IReadModels } from '../../IReadModels.js';
14
+ import { ReadModels } from '../../ReadModels.js';
15
+
16
+ class AccountOpened {}
17
+
18
+ export class ContactDetails {
19
+ @field(String)
20
+ @pii()
21
+ phone!: string;
22
+ }
23
+
24
+ @fromEvent(AccountOpened)
25
+ export class AccountDocument {
26
+ @field(String)
27
+ @subject()
28
+ id!: string;
29
+
30
+ @field(String)
31
+ @pii()
32
+ name!: string;
33
+
34
+ @field(String)
35
+ @pii()
36
+ email!: string;
37
+
38
+ @field(ContactDetails)
39
+ contact!: ContactDetails;
40
+
41
+ @field(Array, { genericArguments: [ContactDetails] })
42
+ contacts!: ContactDetails[];
43
+ }
44
+
45
+ export class a_stored_read_model {
46
+ readonly release = vi.fn<(request: ReleaseRequest) => Promise<ReleaseResponse>>()
47
+ .mockImplementation(async request => ({ HasError: false, Error: '', Payload: request.Payload }));
48
+ readonly readModels: IReadModels;
49
+
50
+ constructor() {
51
+ const connection = { compliance: { release: this.release } } as unknown as ChronicleConnection;
52
+ const artifacts: IClientArtifactsProvider = {
53
+ eventTypes: [], readModels: [AccountDocument], projections: [], reducers: [], reactors: [],
54
+ seeders: [], constraints: [], webhooks: [], eventTypeMigrations: [], globalForHandlers: []
55
+ };
56
+ this.readModels = new ReadModels('store', 'tenant', connection, artifacts, 'sink');
57
+ }
58
+
59
+ requestFor(subject: string): ReleaseRequest {
60
+ return this.release.mock.calls.map(([request]) => request).find(request => request.Subject === subject)!;
61
+ }
62
+ }