Add diagnostics console logs (#7462)

* feat: add diagnostics console logs

* test: avoid secret-like redaction fixtures

* fix: address console logs review feedback

* fix: harden console log capture lifecycle

* fix: report console log drop summaries

* fix: address diagnostics review cleanups
This commit is contained in:
Sipke Schoorstra 2026-05-18 02:16:46 +02:00 committed by GitHub
parent 6485f05a87
commit 43108c2e48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 3365 additions and 4 deletions

View file

@ -1,3 +1,3 @@
{
"feature_directory": "specs/006-state-machine-activity"
"feature_directory": "specs/006-diagnostics-console-logs"
}

View file

@ -83,7 +83,7 @@ Before handing off changes, verify the following when applicable:
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read `specs/006-state-machine-activity/plan.md`.
shell commands, and other important information, read `specs/006-diagnostics-console-logs/plan.md`.
<!-- SPECKIT END -->
## Active Technologies
@ -93,8 +93,11 @@ shell commands, and other important information, read `specs/006-state-machine-a
- Existing bounded in-memory ring buffer; no EF Core schema changes. Provider abstraction remains available for future shared backends. (004-diagnostics-structured-logs)
- C# latest, nullable reference types enabled, implicit usings enabled. + Existing `Elsa.Diagnostics.StructuredLogs`, `Microsoft.Extensions.Logging`, `Microsoft.Extensions.Options`, `Microsoft.AspNetCore.SignalR`, Elsa feature/module infrastructure, FastEndpoints through Elsa API endpoint patterns, FluentMigrator runner packages, SQLite ADO.NET provider, and optionally Dapper for relational operations. (005-structured-log-persistence)
- Bounded in-memory store by default; opt-in SQLite durable store through shared relational persistence. SQLite stores `Timestamp` and `ReceivedAt` as UTC ISO-8601 text and stores exception, scope, and property payloads as JSON text. (005-structured-log-persistence)
- C# latest, nullable reference types enabled, implicit usings enabled. + `Microsoft.Extensions.Options`, `Microsoft.AspNetCore.SignalR`, Elsa feature/module infrastructure, FastEndpoints through Elsa API endpoint patterns, Elsa shell feature infrastructure, and existing Elsa identity/authorization patterns. (006-diagnostics-console-logs)
- Bounded in-memory recent buffer and bounded subscriber queues by default; no durable database schema. Providers receive redacted content only. (006-diagnostics-console-logs)
## Recent Changes
- 006-diagnostics-console-logs: Plans raw stdout/stderr console capture with redaction-before-provider boundaries, bounded in-memory recent/live buffers, REST backfill/source endpoints, and a SignalR live hub.
- 005-structured-log-persistence: Plans pluggable structured log storage with in-memory default and opt-in SQLite persistence using FluentMigrator.
- 004-diagnostics-structured-logs: Refactors the unpublished server logs module into diagnostics structured logs and preserves bounded structured `ILogger` capture.
- 003-live-server-logs: Added C# latest, nullable reference types enabled, implicit usings enabled. + `Microsoft.Extensions.Logging`, `Microsoft.AspNetCore.SignalR`, Elsa feature/module infrastructure, FastEndpoints through Elsa API endpoint patterns, existing Elsa identity/authorization features.

View file

@ -333,6 +333,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.Structured
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite.IntegrationTests", "test\integration\Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite.IntegrationTests\Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite.IntegrationTests.csproj", "{FB7836E3-1D05-4123-B4FA-B9E722723603}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.ConsoleLogs", "src\modules\Elsa.Diagnostics.ConsoleLogs\Elsa.Diagnostics.ConsoleLogs.csproj", "{195FD304-EC3F-4350-93C8-AFE80C4E6896}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.ConsoleLogs.UnitTests", "test\unit\Elsa.Diagnostics.ConsoleLogs.UnitTests\Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj", "{D8739449-22DC-42D4-85A4-4BA547B0B458}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Diagnostics.ConsoleLogs.IntegrationTests", "test\integration\Elsa.Diagnostics.ConsoleLogs.IntegrationTests\Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj", "{93E9213A-694D-4AB4-870E-05E44F793133}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -1289,6 +1295,42 @@ Global
{FB7836E3-1D05-4123-B4FA-B9E722723603}.Release|x64.Build.0 = Release|Any CPU
{FB7836E3-1D05-4123-B4FA-B9E722723603}.Release|x86.ActiveCfg = Release|Any CPU
{FB7836E3-1D05-4123-B4FA-B9E722723603}.Release|x86.Build.0 = Release|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Debug|Any CPU.Build.0 = Debug|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Debug|x64.ActiveCfg = Debug|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Debug|x64.Build.0 = Debug|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Debug|x86.ActiveCfg = Debug|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Debug|x86.Build.0 = Debug|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Release|Any CPU.ActiveCfg = Release|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Release|Any CPU.Build.0 = Release|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Release|x64.ActiveCfg = Release|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Release|x64.Build.0 = Release|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Release|x86.ActiveCfg = Release|Any CPU
{195FD304-EC3F-4350-93C8-AFE80C4E6896}.Release|x86.Build.0 = Release|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Debug|x64.ActiveCfg = Debug|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Debug|x64.Build.0 = Debug|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Debug|x86.ActiveCfg = Debug|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Debug|x86.Build.0 = Debug|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Release|Any CPU.Build.0 = Release|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Release|x64.ActiveCfg = Release|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Release|x64.Build.0 = Release|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Release|x86.ActiveCfg = Release|Any CPU
{D8739449-22DC-42D4-85A4-4BA547B0B458}.Release|x86.Build.0 = Release|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Debug|Any CPU.Build.0 = Debug|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Debug|x64.ActiveCfg = Debug|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Debug|x64.Build.0 = Debug|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Debug|x86.ActiveCfg = Debug|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Debug|x86.Build.0 = Debug|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Release|Any CPU.ActiveCfg = Release|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Release|Any CPU.Build.0 = Release|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Release|x64.ActiveCfg = Release|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Release|x64.Build.0 = Release|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Release|x86.ActiveCfg = Release|Any CPU
{93E9213A-694D-4AB4-870E-05E44F793133}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -1401,10 +1443,13 @@ Global
{96C2CA98-5CFA-4983-A551-CD570CD8CFA7} = {18453B51-25EB-4317-A4B3-B10518252E92}
{2530E106-A168-4C57-9DFC-AB3F2CBD36AE} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
{78FD90A4-90A5-445F-97F2-74BA835AFA5D} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{695814F0-7E8F-469E-9A5A-E46759A4D67C} = {18453B51-25EB-4317-A4B3-B10518252E92}
{FB7836E3-1D05-4123-B4FA-B9E722723603} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
{FDABE591-B1A1-4842-9332-04A894D76C05} = {78FD90A4-90A5-445F-97F2-74BA835AFA5D}
{A0D906D7-9E4D-4C50-93B4-8720BB0AAFA7} = {78FD90A4-90A5-445F-97F2-74BA835AFA5D}
{695814F0-7E8F-469E-9A5A-E46759A4D67C} = {18453B51-25EB-4317-A4B3-B10518252E92}
{FB7836E3-1D05-4123-B4FA-B9E722723603} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
{195FD304-EC3F-4350-93C8-AFE80C4E6896} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{D8739449-22DC-42D4-85A4-4BA547B0B458} = {18453B51-25EB-4317-A4B3-B10518252E92}
{93E9213A-694D-4AB4-870E-05E44F793133} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Diagnostics Console Logs
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-18
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation completed against the generated specification on 2026-05-18.
- Endpoint, hub, permission, and provider-boundary requirements are included because they are explicit product contract requirements for this Core feature and match surrounding diagnostics specs.
- No blocking clarification markers remain. Roadmap open questions were resolved as configurable behavior where possible.

View file

@ -0,0 +1,72 @@
# Provider Contract: Diagnostics Console Logs
Providers receive only redacted line text and redacted source metadata.
## IConsoleLogProvider
Responsibilities:
- Accept redacted `ConsoleLogLine` values.
- Return recent lines for a `ConsoleLogFilter`.
- Stream future lines and dropped summaries for a `ConsoleLogFilter`.
- List known redacted `ConsoleLogSource` values and source health.
```csharp
public interface IConsoleLogProvider
{
ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default);
ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default);
IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default);
}
```
## IConsoleLogRedactor
```csharp
public interface IConsoleLogRedactor
{
ConsoleLogLine Redact(ConsoleLogLine line);
ConsoleLogSource Redact(ConsoleLogSource source);
}
```
## IConsoleLogSourceRegistry
```csharp
public interface IConsoleLogSourceRegistry
{
event Action<ConsoleLogSource>? SourceChanged;
ConsoleLogSource Current { get; }
void MarkSeen(string sourceId, DateTimeOffset timestamp);
IReadOnlyCollection<ConsoleLogSource> List();
}
```
## IConsoleLogCapture
```csharp
public interface IConsoleLogCapture : IAsyncDisposable
{
ValueTask StartAsync(CancellationToken cancellationToken = default);
ValueTask StopAsync(CancellationToken cancellationToken = default);
}
```
## In-Memory Provider
`InMemoryConsoleLogProvider` stores redacted lines in a bounded recent-history buffer and broadcasts live lines through bounded subscriber queues. It is the default provider for local development, tests, and single-node hosts.
## Provider Boundaries
- Providers must not receive raw unredacted console content.
- Providers must preserve deterministic ordering for overlapping source timestamps using received order or another stable tiebreaker.
- Provider failures return safe errors through endpoints or hub summaries without exposing unredacted content.

View file

@ -0,0 +1,90 @@
# REST API Contract: Diagnostics Console Logs
All endpoints use the Elsa API route prefix and require `read:diagnostics:console-logs`.
## Get Recent Console Logs
`POST /diagnostics/console-logs/recent`
Request body is `ConsoleLogFilter`.
```json
{
"sourceId": "local",
"stream": "stdout",
"query": "workflow",
"from": "2026-05-18T10:00:00Z",
"to": "2026-05-18T10:05:00Z",
"limit": 100
}
```
Response body is `RecentConsoleLogsResult`.
```json
{
"items": [
{
"id": "01j...",
"timestamp": "2026-05-18T10:00:01Z",
"receivedAt": "2026-05-18T10:00:01Z",
"sequence": 42,
"stream": "stdout",
"text": "Workflow order-123 started",
"source": {
"id": "local",
"displayName": "elsa-server",
"serviceName": "Elsa.Server.Web",
"processId": 12345,
"machineName": "dev-machine",
"podName": null,
"containerName": null,
"namespace": null,
"nodeName": null,
"lastSeen": "2026-05-18T10:00:01Z",
"health": "connected"
},
"truncated": false,
"dropped": null
}
],
"dropped": []
}
```
Rules:
- The server clamps `limit` to `ConsoleLogsOptions.MaxRecentQuerySize`.
- Returned text and source metadata are redacted.
- ANSI escape sequences are stripped by default unless the host preserves them.
- Results are ordered deterministically by received order with a stable source-aware tiebreaker.
## List Console Log Sources
`GET /diagnostics/console-logs/sources`
Response body is a collection of `ConsoleLogSource`.
```json
[
{
"id": "local",
"displayName": "elsa-server",
"serviceName": "Elsa.Server.Web",
"processId": 12345,
"machineName": "dev-machine",
"podName": null,
"containerName": null,
"namespace": null,
"nodeName": null,
"lastSeen": "2026-05-18T10:00:01Z",
"health": "connected"
}
]
```
Rules:
- Source listing requires the same `read:diagnostics:console-logs` permission as line access.
- Sensitive source metadata is redacted before providers store or return it.
- Stale and disconnected sources remain listable while their recent history is still retained.

View file

@ -0,0 +1,67 @@
# SignalR Hub Contract: Diagnostics Console Logs
## Hub Route
`/elsa/hubs/diagnostics/console-logs`
Requires an authenticated user authorized for `read:diagnostics:console-logs`.
## Client-to-Server Methods
### SubscribeAsync
```csharp
Task SubscribeAsync(ConsoleLogFilter filter);
```
Creates or replaces the caller's live console-log subscription.
### UpdateFilterAsync
```csharp
Task UpdateFilterAsync(ConsoleLogFilter filter);
```
Replaces the caller's active filter without reconnecting.
### UnsubscribeAsync
```csharp
Task UnsubscribeAsync();
```
Stops the caller's active subscription and releases its resources.
## Server-to-Client Methods
### ReceiveConsoleLogLineAsync
Payload is `ConsoleLogLine`.
```csharp
Task ReceiveConsoleLogLineAsync(ConsoleLogLine line);
```
### ReceiveDroppedLinesAsync
Payload is `ConsoleLogDroppedSummary`.
```csharp
Task ReceiveDroppedLinesAsync(ConsoleLogDroppedSummary summary);
```
### ReceiveSourceChangedAsync
Payload is `ConsoleLogSource`.
```csharp
Task ReceiveSourceChangedAsync(ConsoleLogSource source);
```
## Backpressure
Subscriber queues remain bounded. When a subscriber cannot keep up, the provider drops live lines for that subscriber and sends a dropped-line summary when possible.
## Filtering
Live subscriptions support source ID, stream, free-text query, time range, and server-clamped limits where applicable. Filter updates take effect without requiring reconnect.

View file

@ -0,0 +1,93 @@
# Data Model: Diagnostics Console Logs
## ConsoleLogLine
Redacted raw stdout or stderr line exposed by Core.
- `Id`: unique event identifier.
- `Timestamp`: timestamp associated with the write when available.
- `ReceivedAt`: backend receive timestamp.
- `Sequence`: source-local monotonic sequence.
- `Stream`: `stdout` or `stderr`.
- `Text`: redacted line text after ANSI default handling.
- `Source`: `ConsoleLogSource` descriptor.
- `Truncated`: whether the line exceeded the configured maximum line length.
- `Dropped`: optional dropped-line metadata associated with the source or subscriber.
## ConsoleLogSource
Backend process, pod, container, machine, or provider source that produced console output.
- `Id`: stable source identifier.
- `DisplayName`: operator-facing name.
- `ServiceName`: service or application name.
- `ProcessId`: process identifier when available.
- `MachineName`: machine or host name.
- `PodName`, `ContainerName`, `Namespace`, `NodeName`: container/orchestrator metadata when available.
- `LastSeen`: most recent line or heartbeat timestamp.
- `Health`: `connected`, `stale`, or `disconnected`.
- `Metadata`: optional redacted provider metadata.
## ConsoleLogFilter
Criteria applied to recent queries and live subscriptions.
- `SourceId`: optional source filter.
- `Stream`: optional `stdout` or `stderr` filter.
- `Query`: optional free-text search over redacted line text and source fields.
- `From`, `To`: optional received-time range.
- `Limit`: requested recent count, clamped by server options.
## RecentConsoleLogsResult
Backfill response returned by the recent lines endpoint.
- `Items`: ordered `ConsoleLogLine` collection.
- `Dropped`: dropped-line summaries relevant to the result when known.
- `Sources`: optional source status snapshot if the endpoint chooses to include it.
## ConsoleLogDroppedSummary
Bounded-buffer or subscriber overflow summary.
- `SourceId`: affected source when known.
- `Stream`: affected stream when known.
- `Reason`: buffer overflow, subscriber overflow, provider unavailable, or other safe reason.
- `Count`: number of dropped lines.
- `From`, `To`: optional time span covered by the summary.
## ConsoleLogProvider
Replaceable provider facade for redacted console logs.
- Stores redacted recent lines in bounded history.
- Streams redacted live lines and dropped summaries.
- Lists redacted sources and source health.
- Does not receive or retain unredacted line text or sensitive source metadata.
## ConsoleCaptureTee
Capture boundary that observes stdout/stderr writes while preserving original destinations.
- Buffers partial writes until newline, maximum line length, or idle flush timeout.
- Emits one truncated event when a line exceeds maximum length.
- Avoids recursively capturing diagnostics generated by this feature unless explicitly enabled.
## ConsoleRedactionRule
Configured matching rule for sensitive line text or source metadata.
- `Name`: rule identifier.
- `Pattern`: configured match expression or equivalent matcher.
- `Replacement`: redaction marker.
- `AppliesTo`: line text, source metadata, or both.
## ConsoleLogSubscription
Live SignalR subscription state.
- `ConnectionId`: SignalR connection identifier.
- `Filter`: active `ConsoleLogFilter`.
- `QueueCapacity`: bounded subscriber queue capacity.
- `DroppedCount`: subscriber-local dropped-line count.
- `Cancellation`: cancellation state for unsubscribe/disconnect.

View file

@ -0,0 +1,133 @@
# Implementation Plan: Diagnostics Console Logs
**Branch**: `006-diagnostics-console-logs` | **Date**: 2026-05-18 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/006-diagnostics-console-logs/spec.md`
## Summary
Add an opt-in Core diagnostics console logs module that captures raw stdout/stderr line output, redacts it before provider boundaries, keeps bounded recent history, and exposes source-aware REST and SignalR contracts for Studio. The first provider is in-process and single-node, while source identity and provider contracts leave room for future shared aggregation without changing Studio-facing payloads.
## Technical Context
**Language/Version**: C# latest, nullable reference types enabled, implicit usings enabled.
**Primary Dependencies**: `Microsoft.Extensions.Options`, `Microsoft.AspNetCore.SignalR`, Elsa feature/module infrastructure, FastEndpoints through Elsa API endpoint patterns, Elsa shell feature infrastructure, and existing Elsa identity/authorization patterns.
**Storage**: Bounded in-memory recent buffer and bounded subscriber queues by default; no durable database schema. Providers receive redacted content only.
**Testing**: xUnit unit tests for capture buffering, truncation, ANSI handling, redaction, filters, source health, provider behavior, and dropped-line accounting; integration tests for REST endpoints, permission enforcement, SignalR subscribe/update/unsubscribe, and feature registration.
**Target Platform**: ASP.NET Core Elsa Server on the repository's supported .NET target frameworks.
**Project Type**: Modular .NET library inside the existing Elsa solution.
**Performance Goals**: Deliver complete stdout/stderr test lines to authorized subscribers within 1 second up to configured subscriber capacity; keep recent and live buffers bounded under sustained overload.
**Constraints**: Feature is Core-only and separate from `Elsa.Diagnostics.StructuredLogs`; no direct Kubernetes, Docker, vendor sink, durable audit storage, or OpenTelemetry integration. Redaction and ANSI default stripping run before provider storage or streaming.
**Scale/Scope**: New diagnostics console logs module, contracts, options, in-process capture/provider, REST endpoints, SignalR hub, permission, shell feature, README/quickstart, unit tests, and integration tests.
## Constitution Check
Evaluated against `.specify/memory/constitution.md` v1.1.0:
| Principle | Verdict | Evidence |
|-----------|---------|----------|
| I. Modular Architecture | PASS | The feature is a focused module under `src/modules/Elsa.Diagnostics.ConsoleLogs` with its own contracts, services, endpoints, real-time hub, options, permissions, and shell feature. |
| II. Composition & Extensibility | PASS | Console provider, redactor, source registry, capture tee, and options are explicit extension points; external providers can aggregate later without endpoint or hub changes. |
| III. Convention-Driven Design | PASS | The plan follows existing Elsa endpoint, feature, extension, permission, shell feature, and test project naming patterns. |
| IV. Async & Pipeline Execution | PASS | Provider queries, live streams, SignalR methods, and endpoint handlers are async and cancellation-aware. |
| V. Testing Discipline | PASS | The design calls for unit and integration tests around capture semantics, security, redaction, bounded buffers, endpoints, and hub behavior. |
| VI. Trunk-Based Development | PASS | Work is scoped to one Core diagnostics module and can merge independently from the paired Studio feature. |
| VII. Simplicity, SRP, DRY & KISS | PASS | The first slice uses one in-process provider and excludes durable storage, orchestrator APIs, vendor sinks, and OpenTelemetry exploration. |
## Project Structure
### Documentation (this feature)
```text
specs/006-diagnostics-console-logs/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── provider-contract.md
│ ├── rest-api.md
│ └── signalr-hub.md
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
src/modules/
└── Elsa.Diagnostics.ConsoleLogs/
├── Contracts/
│ ├── IConsoleLogCapture.cs
│ ├── IConsoleLogProvider.cs
│ ├── IConsoleLogRedactor.cs
│ └── IConsoleLogSourceRegistry.cs
├── Endpoints/ConsoleLogs/
│ ├── Recent/Endpoint.cs
│ └── Sources/Endpoint.cs
├── Extensions/
├── Features/ConsoleLogsFeature.cs
├── Models/
├── Options/ConsoleLogsOptions.cs
├── Permissions/ConsoleLogsPermissions.cs
├── Providers/InMemory/
├── RealTime/ConsoleLogsHub.cs
├── Services/
└── ShellFeatures/ConsoleLogsFeature.cs
test/unit/
└── Elsa.Diagnostics.ConsoleLogs.UnitTests/
test/integration/
└── Elsa.Diagnostics.ConsoleLogs.IntegrationTests/
```
**Structure Decision**: Create a new Core diagnostics module parallel to `Elsa.Diagnostics.StructuredLogs`. Keep console capture, redaction, buffering, REST, SignalR, and provider abstractions in this module; do not add Studio code or durable/external provider projects.
## Phase 0 Output
See [research.md](./research.md).
Resolved decisions:
- Use a separate `Elsa.Diagnostics.ConsoleLogs` module instead of extending structured logs.
- Capture stdout/stderr through an in-process tee that preserves original console destinations.
- Buffer partial writes until newline, max line length, or idle flush timeout.
- Truncate overlong lines into one marked event and strip ANSI by default.
- Redact before provider boundaries; providers receive redacted content only.
- Use REST for recent backfill/source listing and SignalR for mutable live subscriptions.
## Phase 1 Output
- [data-model.md](./data-model.md)
- [contracts/rest-api.md](./contracts/rest-api.md)
- [contracts/signalr-hub.md](./contracts/signalr-hub.md)
- [contracts/provider-contract.md](./contracts/provider-contract.md)
- [quickstart.md](./quickstart.md)
## Post-Design Constitution Re-Check
| Principle | Verdict | Post-design evidence |
|-----------|---------|----------------------|
| I. Modular Architecture | PASS | Contracts and contracts docs keep the console logs surface inside one focused module. |
| II. Composition & Extensibility | PASS | Provider and source contracts allow shared aggregation later without changing REST or hub contracts. |
| III. Convention-Driven Design | PASS | Endpoint, hub, permission, option, and shell feature names use diagnostics console logs consistently. |
| IV. Async & Pipeline Execution | PASS | Provider, endpoint, and hub contracts are async and cancellation-aware. |
| V. Testing Discipline | PASS | Quickstart and plan name targeted build/test commands and the contract docs identify security and transport checks. |
| VI. Trunk-Based Development | PASS | Core-only artifacts are independent from Studio and external provider work. |
| VII. Simplicity, SRP, DRY & KISS | PASS | The design avoids durable storage and orchestrator integrations while preserving clear extension points. |
## Phase 2 Handoff
Use `/speckit-tasks` to generate the implementation backlog. Suggested order:
1. Create the module and test projects with feature/options/permission skeletons.
2. Add models, provider, source registry, redactor, capture tee, and bounded in-memory behavior.
3. Add REST endpoints and SignalR hub contracts.
4. Add shell feature, service registration, hub mapping, README, and sample host wiring.
5. Add unit and integration tests, then run targeted builds/tests.
## Complexity Tracking
No constitution violations identified.

View file

@ -0,0 +1,71 @@
# Quickstart: Diagnostics Console Logs
## Configure the module
```csharp
services.AddElsa(elsa =>
{
elsa.UseConsoleLogs(options =>
{
options.RecentLogCapacity = 5_000;
options.SubscriberChannelCapacity = 1_000;
options.MaxRecentQuerySize = 1_000;
options.MaxLineLength = 16_384;
options.StripAnsiEscapeSequences = true;
});
});
```
## Map the hub
```csharp
app.UseConsoleLogs();
```
This maps the SignalR hub at `/elsa/hubs/diagnostics/console-logs`. FastEndpoints maps recent-line and source-list endpoints under the configured Elsa API prefix at `/diagnostics/console-logs/recent` and `/diagnostics/console-logs/sources`.
## Shell feature configuration
Shell-based hosts enable the feature with the diagnostics console logs shell feature name:
```json
{
"ShellFeatures": {
"Elsa.Diagnostics.ConsoleLogs.ShellFeatures.ConsoleLogsFeature": {
"RecentLogCapacity": 5000,
"SubscriberChannelCapacity": 1000,
"MaxRecentQuerySize": 1000,
"MaxLineLength": 16384,
"IdleFlushTimeout": "00:00:01",
"StripAnsiEscapeSequences": true,
"IncludeConsoleLogsInternalLogs": false
}
}
}
```
## Authorization
Grant operational users the `read:diagnostics:console-logs` permission. Recent lines, source listing, and live hub access all require this same permission.
## What this module captures
The module captures raw stdout and stderr lines from the backend process while preserving the host's original console destinations. It emits line-oriented, source-aware, redacted console log events.
This module does not parse structured `ILogger` events, persist durable audit logs, call Kubernetes or Docker log APIs, integrate vendor sinks, or implement OpenTelemetry trace/metric exploration.
## Validation
Run targeted checks after implementation:
```bash
dotnet build src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj
dotnet test test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj
dotnet test test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj
```
Validation on 2026-05-18:
- `dotnet test test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj` passed with 23 tests.
- `dotnet test test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj` passed with 9 tests.
- Boundary scan found only explicit out-of-scope references and `Sequence`/`EscapeSequences` identifier matches.

View file

@ -0,0 +1,64 @@
# Research: Diagnostics Console Logs
## Decision: Create a separate diagnostics console logs module
**Rationale**: Raw stdout/stderr capture has different semantics, security risks, and provider boundaries than structured `ILogger` records. A focused `Elsa.Diagnostics.ConsoleLogs` module keeps structured logs, console logs, and future OpenTelemetry exploration independently deployable and understandable.
**Alternatives considered**:
- Extend `Elsa.Diagnostics.StructuredLogs`: rejected because it would mix raw console capture with semantic logging.
- Add console capture to app hosts only: rejected because Studio needs a stable Core feature contract.
## Decision: Use an in-process capture tee as the default capture boundary
**Rationale**: The first slice must work for local development, tests, and single-node hosts while preserving host stdout/stderr behavior. A tee can observe writes, keep the original console destination active, and feed redaction/provider pipelines.
**Alternatives considered**:
- Replace stdout/stderr without teeing: rejected because it would break host-visible console output.
- Use orchestrator log APIs first: rejected as out of scope and provider-specific.
## Decision: Keep line-oriented events with buffered partial writes
**Rationale**: The Studio-facing contract is simpler and safer when events are complete lines. Buffering partial writes until newline, max line length, or idle timeout avoids fragment assembly in clients while still exposing long-running partial output.
**Alternatives considered**:
- Stream fragments immediately: rejected because clients would need assembly state and fragment contracts.
- Drop partial writes: rejected because shutdown and long-running progress output could be lost.
## Decision: Truncate oversized lines and strip ANSI by default
**Rationale**: A single truncated event with a truncation flag preserves bounded memory and simple ordering. Stripping ANSI by default avoids leaking terminal control sequences to browser clients while still allowing host opt-in preservation.
**Alternatives considered**:
- Split oversized lines into chunks: rejected because it adds chunk metadata and client reassembly complexity.
- Preserve ANSI by default: rejected because browser rendering and security expectations should start from plain text.
## Decision: Redact before provider storage, streaming, or source listing
**Rationale**: Providers may later become shared or external. Keeping raw unredacted content inside the capture/redaction boundary prevents accidental persistence or cross-node exposure and matches the authorization posture.
**Alternatives considered**:
- Let providers receive raw content internally: rejected because provider implementations would each need to prove redaction safety.
- Make redaction provider-specific: rejected because it weakens the Core contract.
## Decision: Use REST backfill/source endpoints plus SignalR live subscriptions
**Rationale**: Existing diagnostics structured logs already use REST for recent queries and source listing, and SignalR for live mutable subscriptions. Mirroring that split keeps Studio integration predictable and allows bounded backfill before live streaming.
**Alternatives considered**:
- Server-sent events only: rejected because the repository already has SignalR patterns for authenticated live diagnostics.
- WebSocket-only backfill and live data: rejected because source listing and recent queries are simpler and more testable over REST endpoints.
## Decision: Keep external aggregators and durable retention out of scope
**Rationale**: The spec requires source identity and provider boundaries, not direct Kubernetes, Docker, vendor sink, durable audit, or OpenTelemetry integrations. A bounded in-memory provider satisfies the first Core slice without speculative packages.
**Alternatives considered**:
- Add SQLite persistence now: rejected because recent console history is operational troubleshooting data, not durable audit logging.
- Add Kubernetes/Docker providers now: rejected because external provider requirements need separate specs.

View file

@ -0,0 +1,172 @@
# Feature Specification: Diagnostics Console Logs
**Feature Branch**: `006-diagnostics-console-logs`
**Created**: 2026-05-18
**Status**: Draft
**Input**: User description: "Create a Core feature spec for diagnostics console streaming based on the existing roadmap at specs/diagnostics-console-streaming-roadmap.md and the surrounding context from specs/003-live-server-logs, specs/004-diagnostics-structured-logs, and specs/005-structured-log-persistence. Include requirements for backend capture, buffering, endpoints, SignalR hub, permissions, source identity, redaction, and provider boundaries. Avoid implementation code changes."
## Clarifications
### Session 2026-05-18
- Q: How should Core expose partial stdout/stderr writes that have not ended with a newline? -> A: Buffer until complete.
- Q: How should Core handle console lines longer than the configured maximum line length? -> A: Truncate oversized lines.
- Q: What should Core do with ANSI escape sequences by default? -> A: Strip ANSI by default.
- Q: What permission boundary should Core use for source listings? -> A: Same dedicated permission.
- Q: What content should console log providers receive and retain? -> A: Redacted content only.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Tail raw backend console output (Priority: P1)
An administrator or developer enables the Core console logs feature and watches recent plus live stdout and stderr lines from the backend process without using shell access.
**Why this priority**: Raw process console output is the core capability and is intentionally separate from structured log records.
**Independent Test**: Enable the console logs feature, write distinct lines to stdout and stderr, request recent lines, subscribe to the live stream, and verify callers receive ordered, source-aware console line events while the original console output still reaches its normal destination.
**Acceptance Scenarios**:
1. **Given** diagnostics console logs are enabled and the caller is authorized, **When** the backend writes complete lines to stdout and stderr, **Then** the caller receives console line events with stream identity, text, timestamps, sequence, and source identity.
2. **Given** console capture is enabled, **When** the backend writes to stdout or stderr, **Then** existing console behavior is preserved and output remains visible to the host environment.
3. **Given** the caller requests recent console lines before subscribing live, **When** recent matching lines exist, **Then** the server returns a bounded ordered backfill before the caller receives new live lines.
---
### User Story 2 - Filter, secure, and redact console output (Priority: P2)
An operator narrows noisy console output by source, stream, text, and time while the backend enforces a dedicated diagnostics permission and redacts sensitive data before data leaves the backend.
**Why this priority**: Console output can contain secrets and high-volume operational noise. The feature is not safe for real hosts without authorization, filtering, and redaction.
**Independent Test**: Connect authorized and unauthorized callers, write console lines containing secret-like values, apply filters, and verify unauthorized access is rejected while authorized callers only receive redacted lines matching their filters.
**Acceptance Scenarios**:
1. **Given** an unauthenticated or unauthorized caller, **When** the caller requests recent console lines, source listings, or a live subscription, **Then** access is rejected.
2. **Given** redaction rules are configured, **When** stdout or stderr contains matching sensitive values, **Then** recent and live events replace those values with a redaction marker before exposure.
3. **Given** the caller filters by source, stream, text, or time, **When** matching and non-matching console lines are available, **Then** only matching redacted lines are returned or streamed.
---
### User Story 3 - Identify console sources in clustered deployments (Priority: P3)
An operator diagnosing a clustered Elsa deployment can view a merged stream across known backend sources and filter to one source such as a process, pod, or container.
**Why this priority**: Source identity must be part of the first contract so the console viewer does not become single-process-only and can later use shared providers without changing Studio-facing behavior.
**Independent Test**: Simulate multiple console log sources through the provider boundary, request sources, subscribe to a merged stream, and then filter to one source while preserving source health and dropped-line metadata.
**Acceptance Scenarios**:
1. **Given** multiple sources publish console lines through the configured provider, **When** the caller subscribes without a source filter, **Then** the server streams a merged ordered view with each line's source identity.
2. **Given** the caller filters by a source ID, **When** lines arrive from multiple sources, **Then** only lines from the selected source are returned or streamed.
3. **Given** a source stops sending heartbeats or lines, **When** the caller lists sources, **Then** the source is marked stale or disconnected without immediately losing its recent history.
### Edge Cases
- Console writes arrive as partial lines or without a trailing newline.
- A console line exceeds the configured maximum line length.
- Output arrives faster than the recent buffer or subscriber queues can handle.
- A subscriber disconnects while console lines continue to arrive.
- A caller changes filters while subscribed.
- Console output contains ANSI escape sequences.
- Console output contains secrets in raw text or source metadata.
- Multiple sources have clock skew or overlapping sequence values.
- The console logs feature writes its own diagnostics messages and risks feeding them back into the captured console stream.
- The configured provider is unavailable at startup or becomes unavailable during streaming.
## Requirements *(mandatory)*
### Functional Requirements
**Module boundary and feature identity**
- **FR-001**: The backend MUST provide an opt-in Core diagnostics module named `Elsa.Diagnostics.ConsoleLogs` or an equivalent diagnostics console logs name confirmed before implementation.
- **FR-002**: The console logs module MUST be separate from `Elsa.Diagnostics.StructuredLogs` and MUST NOT replace, rename, or parse structured log records.
- **FR-003**: The module MUST advertise a diagnostics console logs remote feature so Studio can detect whether the backend supports console streaming.
- **FR-004**: The feature MUST document that raw stdout/stderr console streaming is distinct from structured logs, structured log persistence, trace waterfalls, metrics, and OpenTelemetry exploration.
**Backend capture and line behavior**
- **FR-005**: The feature MUST capture writes to stdout and stderr as raw console output while preserving the host's existing console output behavior.
- **FR-006**: Captured output MUST be emitted as line-oriented console events with stream identity of `stdout` or `stderr`.
- **FR-007**: Captured console events MUST include ID, timestamp, received timestamp, source-local sequence, stream, text, source identity, truncation indicator, and dropped-line metadata when available.
- **FR-008**: Partial writes MUST be buffered and MUST NOT be exposed as separate fragment events; Core completes and emits a line only on newline, maximum line length, or configurable idle flush timeout.
- **FR-009**: Lines longer than the configured maximum line length MUST be truncated to the configured maximum, emitted as a single console line event, and marked with a truncation indicator.
- **FR-010**: ANSI escape sequences MUST be stripped by default before exposure, and hosts MUST be able to configure preservation of terminal formatting when needed.
- **FR-011**: The feature MUST avoid recursively capturing console diagnostics generated by the console logs feature itself unless explicitly enabled for troubleshooting.
**Buffering and backpressure**
- **FR-012**: The feature MUST keep a bounded recent-history buffer for initial backfill and MUST never grow memory without bound.
- **FR-013**: The feature MUST use bounded live subscriber behavior and MUST track dropped-line counts when buffers or subscriber queues overflow.
- **FR-014**: Recent-line requests MUST enforce a server-side maximum result count regardless of caller input.
- **FR-015**: Dropped-line summaries MUST identify the affected source and reason when that information is known.
**Endpoints and live transport**
- **FR-016**: The backend MUST expose an authenticated recent console lines endpoint for initial backfill, for example `/diagnostics/console-logs/recent`.
- **FR-017**: The backend MUST expose an authenticated console sources endpoint, for example `/diagnostics/console-logs/sources`.
- **FR-018**: The backend MUST expose an authenticated live console logs SignalR hub, for example `/elsa/hubs/diagnostics/console-logs`.
- **FR-019**: Recent queries and live subscriptions MUST support filters for source ID, stream, free-text query, time range, and maximum result count.
- **FR-020**: The live hub MUST allow a caller to subscribe, update filters without reconnecting, and unsubscribe.
- **FR-021**: The live hub MUST send console line events, dropped-line summaries, and source status changes to subscribed callers.
**Authorization and redaction**
- **FR-022**: Recent-line endpoints, source-listing endpoints, and live console log hubs MUST all require the same dedicated permission such as `read:diagnostics:console-logs`.
- **FR-023**: The feature MUST use Elsa's existing authentication, authorization, and cross-origin access patterns.
- **FR-024**: Redaction MUST run before console lines or source metadata are stored in recent buffers, streamed live, or returned by endpoints.
- **FR-025**: Default redaction MUST mask common secret indicators such as authorization, bearer tokens, passwords, secrets, API keys, cookies, connection strings, and similarly named values.
- **FR-026**: Hosts MUST be able to configure additional redaction rules and replacement text.
- **FR-027**: Redaction MUST apply to raw line text and to sensitive source metadata fields before data leaves the backend.
- **FR-027a**: Console log providers MUST receive, store, stream, and list only redacted console line text and redacted source metadata; raw unredacted console content MUST remain inside the capture/redaction boundary.
**Source identity and provider boundaries**
- **FR-028**: Every console line MUST include a source descriptor with source ID, display name, service name, process ID, machine name, and, when available, pod name, container name, namespace, and node name.
- **FR-029**: Sources MUST expose last-seen time and health such as connected, stale, or disconnected.
- **FR-030**: The default provider MUST support in-process capture for local development, tests, and single-node hosts.
- **FR-031**: The provider boundary MUST allow future shared or external providers to aggregate console lines across multiple Core instances without changing Studio-facing contracts.
- **FR-032**: Direct Kubernetes, Docker, orchestrator log API, vendor sink, and OpenTelemetry integrations MUST remain out of scope for this feature unless added by later specs.
- **FR-033**: Merged streams MUST provide deterministic ordering when source timestamps overlap or clocks differ, using received order or another stable tiebreaker.
**Configuration and operations**
- **FR-034**: The feature MUST provide host options for recent buffer capacity, subscriber/channel capacity, maximum recent query size, maximum line length, idle flush timeout, ANSI handling, source heartbeat timeout, redaction rules, and provider selection.
- **FR-035**: Provider failures MUST be surfaced to recent queries or live subscriptions with safe error information and without exposing unredacted console content.
- **FR-036**: The feature MUST include validation coverage for capture, stream identity, filtering, buffering, dropped counts, authorization, redaction, source health, provider boundaries, and feedback-loop prevention.
### Key Entities *(include if feature involves data)*
- **Console Log Line**: A redacted raw stdout or stderr line with sequence, timestamps, stream identity, text, truncation state, source identity, and dropped-line context.
- **Console Log Source**: The process, pod, container, machine, or external provider source that produced console output.
- **Console Log Filter**: Criteria used for recent queries and live subscriptions, including source, stream, text, time range, and take limit.
- **Console Log Provider**: Replaceable provider that stores recent console lines, streams future lines, lists sources, and reports dropped-line/source status.
- **Console Capture Tee**: Capture boundary that observes stdout/stderr while preserving the original console destination.
- **Console Redaction Rule**: Configured matching rule that masks sensitive raw line text or source metadata before exposure.
- **Console Log Subscription**: Live connection with active filters, bounded queue behavior, and dropped-line summaries.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In single-process validation, authorized subscribers receive 100% of complete stdout and stderr test lines up to configured subscriber capacity within 1 second of capture.
- **SC-002**: Recent-line queries never return more than the configured maximum number of lines.
- **SC-003**: Unauthorized callers are rejected for 100% of recent-line, source-list, and live hub access attempts.
- **SC-004**: Default redaction masks common secret patterns in raw console line text and sensitive source metadata in validation tests.
- **SC-005**: Under sustained overload, memory remains bounded and dropped-line counts are visible through recent results or live summaries.
- **SC-006**: In simulated multi-source validation, merged results include lines from all active sources and source filtering returns only the selected source.
- **SC-007**: A source that stops sending lines or heartbeats is marked stale within the configured heartbeat timeout.
- **SC-008**: Console writes continue reaching the host's original stdout/stderr destination while capture is enabled.
- **SC-009**: Documentation or feature metadata clearly separates console logs from structured logs, structured log persistence, and future OpenTelemetry diagnostics.
## Assumptions
- Studio will implement a paired feature spec in the `elsa-studio` repository and is owned by another worker.
- The Core route names, hub route, remote feature name, and permission use diagnostics console logs naming unless later renamed consistently before implementation.
- In-process capture is the first slice; cluster-ready behavior is provided through source identity and provider boundaries rather than direct orchestrator APIs.
- Recent console history is operational troubleshooting data, not durable audit logging.
- Redacted console lines are the only form exposed by providers, endpoints, hubs, and buffers.
- Hosts that need long-term log retention should continue using their existing observability or platform log systems.

View file

@ -0,0 +1,235 @@
# Tasks: Diagnostics Console Logs
**Input**: Design documents from `/specs/006-diagnostics-console-logs/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), [data-model.md](./data-model.md), [contracts/](./contracts), [quickstart.md](./quickstart.md)
**Tests**: Included because the specification requires validation coverage for capture, stream identity, filtering, buffering, dropped counts, authorization, redaction, source health, provider boundaries, and feedback-loop prevention.
**Organization**: Tasks are grouped by user story so each increment can be implemented and tested independently.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel with other marked tasks after prerequisites are satisfied.
- **[Story]**: User story label from [spec.md](./spec.md).
- Every task includes the primary file path to edit or create.
## Phase 1: Setup
**Purpose**: Create the Core module and test project skeletons.
- [X] T001 [P] Create console logs module project in `src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj`.
- [X] T002 [P] Create unit test project in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj`.
- [X] T003 [P] Create integration test project in `test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj`.
- [X] T004 Add the console logs projects to `Elsa.sln`.
- [X] T005 [P] Add module global usings in `src/modules/Elsa.Diagnostics.ConsoleLogs/Usings.cs`.
- [X] T006 [P] Add unit test global usings in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Usings.cs`.
- [X] T007 [P] Add integration test global usings in `test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/Usings.cs`.
---
## Phase 2: Foundational
**Purpose**: Add shared contracts, models, options, registration, and permissions that block all user stories.
**Critical**: No user story work should begin until these shared contracts and registration surfaces exist.
- [X] T008 [P] Add console log models in `src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogLine.cs`.
- [X] T009 [P] Add console source model and health enum in `src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogSource.cs`.
- [X] T010 [P] Add filter, recent result, dropped summary, and stream item models in `src/modules/Elsa.Diagnostics.ConsoleLogs/Models/ConsoleLogFilter.cs`.
- [X] T011 [P] Add provider contract in `src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogProvider.cs`.
- [X] T012 [P] Add redactor contract in `src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogRedactor.cs`.
- [X] T013 [P] Add source registry contract in `src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogSourceRegistry.cs`.
- [X] T014 [P] Add capture contract in `src/modules/Elsa.Diagnostics.ConsoleLogs/Contracts/IConsoleLogCapture.cs`.
- [X] T015 [P] Add host options in `src/modules/Elsa.Diagnostics.ConsoleLogs/Options/ConsoleLogsOptions.cs`.
- [X] T016 [P] Add permission constant in `src/modules/Elsa.Diagnostics.ConsoleLogs/Permissions/ConsoleLogsPermissions.cs`.
- [X] T017 Add service registration extension in `src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs`.
- [X] T018 Add application and endpoint route extensions in `src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ApplicationBuilderExtensions.cs`.
- [X] T019 Add module feature registration in `src/modules/Elsa.Diagnostics.ConsoleLogs/Features/ConsoleLogsFeature.cs`.
- [X] T020 Add shell feature registration in `src/modules/Elsa.Diagnostics.ConsoleLogs/ShellFeatures/ConsoleLogsFeature.cs`.
- [X] T021 [P] Add feature and naming contract tests in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsNamingTests.cs`.
- [X] T022 [P] Add default registration tests in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/ConsoleLogsRegistrationTests.cs`.
**Checkpoint**: Shared contracts, options, permission, and feature registration are ready for story work.
---
## Phase 3: User Story 1 - Tail raw backend console output (Priority: P1) MVP
**Goal**: Authorized users can request recent stdout/stderr lines and receive live console line events while host console output still reaches its original destination.
**Independent Test**: Enable console logs, write distinct complete stdout/stderr lines, request recent lines, subscribe live, and verify ordered source-aware events plus preserved original console output.
### Tests for User Story 1
- [X] T023 [P] [US1] Add capture tee preservation tests in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleCaptureTeeTests.cs`.
- [X] T024 [P] [US1] Add partial-line buffering and idle flush tests in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleLineBufferTests.cs`.
- [X] T025 [P] [US1] Add truncation and ANSI default handling tests in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleLineFormatterTests.cs`.
- [X] T026 [P] [US1] Add in-memory recent and live provider tests in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderTests.cs`.
- [X] T027 [P] [US1] Add recent endpoint contract tests in `test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsRecentEndpointTests.cs`.
- [X] T028 [P] [US1] Add SignalR subscribe/unsubscribe integration tests in `test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubTests.cs`.
### Implementation for User Story 1
- [X] T029 [P] [US1] Implement line buffer in `src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineBuffer.cs`.
- [X] T030 [P] [US1] Implement line formatter for truncation and ANSI handling in `src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLineFormatter.cs`.
- [X] T031 [US1] Implement stdout/stderr capture tee in `src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs`.
- [X] T032 [US1] Implement in-memory provider with bounded recent history and live queues in `src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/InMemoryConsoleLogProvider.cs`.
- [X] T033 [US1] Implement recent endpoint in `src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Recent/Endpoint.cs`.
- [X] T034 [US1] Implement SignalR client contract in `src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/IConsoleLogsClient.cs`.
- [X] T035 [US1] Implement SignalR hub subscribe and unsubscribe flow in `src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs`.
- [X] T036 [US1] Wire capture startup and shutdown in `src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogCaptureHostedService.cs`.
- [X] T037 [US1] Run `dotnet test test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj`.
**Checkpoint**: User Story 1 is fully functional and independently testable as the MVP.
---
## Phase 4: User Story 2 - Filter, secure, and redact console output (Priority: P2)
**Goal**: Operators can filter console output while Core enforces a dedicated permission and redacts sensitive line text and source metadata before provider boundaries.
**Independent Test**: Connect authorized and unauthorized callers, write secret-like console lines, apply filters, and verify unauthorized access is rejected while authorized callers receive only redacted matching lines.
### Tests for User Story 2
- [X] T038 [P] [US2] Add redaction tests for line text and source metadata in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Redaction/ConsoleLogRedactorTests.cs`.
- [X] T039 [P] [US2] Add filter evaluator tests for source, stream, query, time range, and limit in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Filtering/ConsoleLogFilterTests.cs`.
- [X] T040 [P] [US2] Add authorization tests for recent and source endpoints in `test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs`.
- [X] T041 [P] [US2] Add SignalR authorization and filter-update tests in `test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubAuthorizationTests.cs`.
- [X] T042 [P] [US2] Add redaction-before-provider boundary tests in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Redaction/ConsoleLogProviderRedactionTests.cs`.
### Implementation for User Story 2
- [X] T043 [P] [US2] Implement default redaction rules in `src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogRedactor.cs`.
- [X] T044 [P] [US2] Implement filter evaluator in `src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogFilterEvaluator.cs`.
- [X] T045 [US2] Apply redaction before provider publication in `src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleCaptureTee.cs`.
- [X] T046 [US2] Enforce server-clamped recent query limits in `src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Recent/Endpoint.cs`.
- [X] T047 [US2] Secure recent and source endpoints with `read:diagnostics:console-logs` in `src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Recent/Endpoint.cs`.
- [X] T048 [US2] Secure SignalR hub with `read:diagnostics:console-logs` in `src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs`.
- [X] T049 [US2] Implement hub filter update behavior in `src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs`.
- [X] T050 [US2] Run `dotnet test test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj`.
**Checkpoint**: User Stories 1 and 2 work together with authorization, filtering, and redaction.
---
## Phase 5: User Story 3 - Identify console sources in clustered deployments (Priority: P3)
**Goal**: Operators can view source-aware merged console output, filter to one source, and see source health without changing Studio-facing contracts.
**Independent Test**: Simulate multiple provider sources, request sources, subscribe to merged output, filter to one source, and verify source health plus dropped-line metadata.
### Tests for User Story 3
- [X] T051 [P] [US3] Add source registry tests for current source metadata and health transitions in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Sources/ConsoleLogSourceRegistryTests.cs`.
- [X] T052 [P] [US3] Add multi-source ordering tests in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderSourceTests.cs`.
- [X] T053 [P] [US3] Add dropped-line summary tests for buffer and subscriber overflow in `test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderDroppedLineTests.cs`.
- [X] T054 [P] [US3] Add source endpoint integration tests in `test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsSourcesEndpointTests.cs`.
- [X] T055 [P] [US3] Add source status SignalR integration tests in `test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubSourceStatusTests.cs`.
### Implementation for User Story 3
- [X] T056 [P] [US3] Implement source registry in `src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogSourceRegistry.cs`.
- [X] T057 [US3] Add source health timeout handling in `src/modules/Elsa.Diagnostics.ConsoleLogs/Services/ConsoleLogSourceHealthService.cs`.
- [X] T058 [US3] Add deterministic multi-source ordering in `src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/InMemoryConsoleLogProvider.cs`.
- [X] T059 [US3] Add dropped-line summary publication in `src/modules/Elsa.Diagnostics.ConsoleLogs/Providers/InMemory/InMemoryConsoleLogProvider.cs`.
- [X] T060 [US3] Implement sources endpoint in `src/modules/Elsa.Diagnostics.ConsoleLogs/Endpoints/ConsoleLogs/Sources/Endpoint.cs`.
- [X] T061 [US3] Stream source status changes through SignalR in `src/modules/Elsa.Diagnostics.ConsoleLogs/RealTime/ConsoleLogsHub.cs`.
- [X] T062 [US3] Run `dotnet test test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj`.
**Checkpoint**: All user stories are independently functional and source-aware.
---
## Phase 6: Documentation & Polish
**Purpose**: Update public docs, sample guidance, boundary assertions, and validation.
- [X] T063 [P] Add console logs README in `src/modules/Elsa.Diagnostics.ConsoleLogs/README.md`.
- [X] T064 [P] Update quickstart implementation notes in `specs/006-diagnostics-console-logs/quickstart.md`.
- [X] T065 [P] Add module boundary assertions in `test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsModuleTests.cs`.
- [X] T066 Update sample host wiring only if the sample opts into console logs in `src/apps/Elsa.Server.Web/Program.cs`.
- [X] T067 Run `dotnet build src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj`.
- [X] T068 Run `dotnet test test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Elsa.Diagnostics.ConsoleLogs.UnitTests.csproj`.
- [X] T069 Run `dotnet test test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/Elsa.Diagnostics.ConsoleLogs.IntegrationTests.csproj`.
- [X] T070 Run `rg "StructuredLogs|OpenTelemetry|Kubernetes|Docker|OTLP|Loki|Seq" src/modules/Elsa.Diagnostics.ConsoleLogs specs/006-diagnostics-console-logs` and record boundary findings in `specs/006-diagnostics-console-logs/tasks.md`.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 Setup**: No dependencies.
- **Phase 2 Foundational**: Depends on Phase 1 and blocks all user stories.
- **Phase 3 US1**: Depends on Phase 2; MVP.
- **Phase 4 US2**: Depends on Phase 2 and should follow US1 validation for an end-to-end capture path.
- **Phase 5 US3**: Depends on Phase 2 and can proceed after provider/source contracts exist, but final validation should follow US1.
- **Phase 6 Polish**: Depends on selected story implementation.
### User Story Dependencies
- **US1 (P1)**: First executable slice; no dependency on US2 or US3.
- **US2 (P2)**: Uses US1 capture/provider surfaces but remains independently testable through redaction, filtering, authorization, and hub filter updates.
- **US3 (P3)**: Uses shared provider/source contracts and adds multi-source behavior without changing US1 or US2 contracts.
### Parallel Opportunities
- T001 through T003 and T005 through T007 can run in parallel.
- T008 through T016 and T021 through T022 can run in parallel after project creation.
- US1 tests T023 through T028 can run in parallel.
- US2 tests T038 through T042 can run in parallel.
- US3 tests T051 through T055 can run in parallel.
- Documentation tasks T063 through T065 can run in parallel after implementation APIs settle.
## Parallel Example: User Story 1
```text
Task: "Add capture tee preservation tests in test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleCaptureTeeTests.cs"
Task: "Add partial-line buffering and idle flush tests in test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleLineBufferTests.cs"
Task: "Add truncation and ANSI default handling tests in test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Capture/ConsoleLineFormatterTests.cs"
Task: "Add in-memory recent and live provider tests in test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderTests.cs"
```
## Parallel Example: User Story 2
```text
Task: "Add redaction tests for line text and source metadata in test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Redaction/ConsoleLogRedactorTests.cs"
Task: "Add filter evaluator tests for source, stream, query, time range, and limit in test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Filtering/ConsoleLogFilterTests.cs"
Task: "Add authorization tests for recent and source endpoints in test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsAuthorizationTests.cs"
Task: "Add SignalR authorization and filter-update tests in test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsHubAuthorizationTests.cs"
```
## Parallel Example: User Story 3
```text
Task: "Add source registry tests for current source metadata and health transitions in test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/Sources/ConsoleLogSourceRegistryTests.cs"
Task: "Add multi-source ordering tests in test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderSourceTests.cs"
Task: "Add dropped-line summary tests for buffer and subscriber overflow in test/unit/Elsa.Diagnostics.ConsoleLogs.UnitTests/InMemory/InMemoryConsoleLogProviderDroppedLineTests.cs"
Task: "Add source endpoint integration tests in test/integration/Elsa.Diagnostics.ConsoleLogs.IntegrationTests/ConsoleLogsSourcesEndpointTests.cs"
```
## Implementation Strategy
### MVP First
1. Complete Phase 1 and Phase 2.
2. Complete Phase 3 only.
3. Run the US1 unit and integration tests.
4. Stop and validate recent backfill, live stdout/stderr streaming, and preserved console output.
### Incremental Delivery
1. US1: raw stdout/stderr capture, bounded recent history, and live streaming.
2. US2: authorization, filtering, redaction, and provider-boundary safety.
3. US3: source health, multi-source ordering, dropped summaries, and source endpoint behavior.
4. Polish: docs, sample guidance, boundary checks, builds, and tests.
## Notes
- Boundary scan result: only explicit out-of-scope documentation references were found; source matches for `Sequence` and `StripAnsiEscapeSequences` are expected identifier matches, not external provider integrations.
- Do not touch `elsa-studio` for this Core feature.
- Do not implement durable console log persistence in this feature.
- Do not implement Kubernetes, Docker, vendor sink, or OpenTelemetry integrations.
- Preserve redaction-before-provider boundaries.
- Keep `Elsa.Diagnostics.StructuredLogs` separate from console logs.

View file

@ -0,0 +1,181 @@
# Diagnostics Console Streaming Roadmap
## Purpose
Track the cross-repository plan for Aspire-style console streaming before creating formal Spec Kit feature specs.
This roadmap is intentionally lighter than a feature spec. It records agreed direction, repo ownership, sequencing, and open follow-up work. When implementation begins, create one formal Spec Kit feature spec in each repository:
- Core: `Elsa.Diagnostics.ConsoleLogs`
- Studio: `Elsa.Studio.Diagnostics.ConsoleLogs`
## Product Direction
Console Streaming is a separate diagnostics surface from Structured Logs.
- Structured Logs answer: "What semantic `ILogger` events happened, with properties, scopes, correlation, and trace context?"
- Console Streaming answers: "What raw text is the backend process writing to stdout or stderr right now?"
The user experience should feel similar to Aspire's dashboard console stream: dense, live, source-aware, easy to pause, easy to search, and useful during local development as well as clustered deployments.
## Agreed Boundaries
- Keep Structured Logs in `Elsa.Studio.Diagnostics.StructuredLogs` and `Elsa.Diagnostics.StructuredLogs`.
- Add Console Streaming as its own diagnostics module, not a rename or replacement of Structured Logs.
- Treat stdout and stderr as raw console lines, even when those lines happen to contain formatted log output.
- Do not parse console lines into structured log records.
- Do not add trace waterfalls, metrics, or OpenTelemetry exploration here.
- Use in-process console capture for the first slice.
- Defer direct Kubernetes, Docker, or orchestrator log API integration.
- Keep cluster support provider-driven through source identity and shared streaming/storage abstractions.
## Proposed Names
- Core package/module: `Elsa.Diagnostics.ConsoleLogs`
- Studio package/module: `Elsa.Studio.Diagnostics.ConsoleLogs`
- Studio route: `/diagnostics/console`
- Navigation label: `Console`
- Permission: `read:diagnostics:console-logs`
- Remote feature name: diagnostics-specific and distinct from structured logs.
Final names should be confirmed in the formal specs before implementation.
## Core Responsibilities
Core should own capture, buffering, security, and transport.
- Provide an opt-in console logs feature.
- Capture `Console.Out` and `Console.Error` through a tee so existing console behavior is preserved.
- Emit line-oriented console events with stream identity: stdout or stderr.
- Keep bounded recent history for initial Studio backfill.
- Provide live SignalR streaming.
- Provide REST endpoints for recent lines and known sources.
- Track dropped lines when buffers or subscriber channels overflow.
- Include source identity for each line: source ID, display name, service name, process ID, machine name, and available pod/container metadata.
- Support merged streams across sources and source-specific filtering.
- Support text, stream, source, and time filters.
- Support configurable redaction before lines leave the backend.
- Provide options for buffer capacity, channel capacity, maximum line length, redaction, ANSI handling, source heartbeat timeout, and provider selection.
- Avoid feedback loops from console streaming diagnostics writing into the captured console stream.
## Studio Responsibilities
Studio should own the Aspire-like viewing experience.
- Add a Diagnostics navigation entry labeled `Console`.
- Gate the page on the Core console logs remote feature.
- Load recent lines before connecting to the live stream.
- Connect to the console logs SignalR hub using existing authenticated SignalR patterns.
- Default to a merged `All sources` view.
- Provide a source selector with stale/disconnected source states.
- Show stdout and stderr distinctly.
- Preserve terminal-like density without turning Structured Logs into a console UI.
- Provide pause/resume, follow-tail, clear local view, reconnect, copy visible lines, and download/export visible lines.
- Provide text search with highlights.
- Provide wrap and compact toggles.
- Preserve useful filters in the URL query string.
- Cap rendered rows locally and show when older local rows were discarded.
- Show distinct states for unavailable feature, unauthorized, disconnected, reconnecting, empty, and no matches.
## Candidate Contract Shape
Formal specs should refine these names and fields.
### Console Log Line
- `Id`
- `Timestamp`
- `ReceivedAt`
- `Sequence`
- `Stream`: `stdout` or `stderr`
- `Text`
- `Source`
- `IsTruncated`
- `DroppedBeforeCount`
### Console Log Source
- `Id`
- `DisplayName`
- `ServiceName`
- `ProcessId`
- `MachineName`
- `PodName`
- `ContainerName`
- `Namespace`
- `NodeName`
- `LastSeenAt`
- `Health`
### Console Log Filter
- `SourceId`
- `Streams`
- `Text`
- `From`
- `To`
- `Take`
## Suggested Milestones
### Milestone 1 - Formalize Specs
- Create Core Spec Kit feature spec for `Elsa.Diagnostics.ConsoleLogs`.
- Create Studio Spec Kit feature spec for `Elsa.Studio.Diagnostics.ConsoleLogs`.
- Confirm final route, permission, feature name, and contract names.
- Align both specs on REST and SignalR endpoints.
### Milestone 2 - Core MVP
- Add the opt-in console capture feature.
- Implement stdout/stderr tee capture.
- Add bounded in-memory recent buffer.
- Add source identity for the local process.
- Add recent-lines endpoint.
- Add source-list endpoint.
- Add live SignalR hub.
- Add authorization and redaction.
- Add tests for capture, filtering, buffering, dropped counts, and unauthorized access.
### Milestone 3 - Studio MVP
- Add the Studio console logs module.
- Add Diagnostics navigation and route.
- Add typed API and SignalR clients.
- Build the console viewer with recent backfill plus live streaming.
- Implement source, stream, and text filters.
- Implement pause/resume, follow-tail, clear, reconnect, copy, wrap, and compact controls.
- Add unavailable, unauthorized, disconnected, empty, and no-match states.
- Add component tests or mocked client tests where the repo's current patterns support them.
### Milestone 4 - Cluster-Ready Provider Shape
- Validate that source identity works for multiple Core instances.
- Define or reuse provider abstractions for shared streams.
- Keep Kubernetes/Docker log API integration deferred unless a later spec explicitly includes it.
- Document how clustered deployments should configure shared console streaming.
### Milestone 5 - Polish and Documentation
- Document the distinction between Structured Logs, Console Streaming, and future OpenTelemetry exploration.
- Add quickstarts for local development and clustered deployments.
- Add operational guidance for redaction and permissions.
- Add troubleshooting notes for console capture limitations.
## Open Questions for Formal Specs
- Should ANSI escape sequences be preserved by default, stripped by default, or user-toggleable in Studio?
- Should console lines be split strictly on newline, or should long partial writes be flushed after an idle timeout?
- Should stderr always be highlighted, or only visually tagged?
- Should the backend support downloading recent console lines, or should Studio export only its local visible buffer?
- Should redaction run on the raw line text only, or also on source metadata?
- Should direct console capture be disabled automatically in environments where stdout/stderr are already redirected in unsupported ways?
## Spec Strategy
Use one formal Spec Kit feature per repository when ready:
- Core spec: backend feature, contracts, endpoints, hub, permissions, capture behavior, buffering, provider model, tests.
- Studio spec: module identity, route, navigation, feature gating, API clients, SignalR client, viewer UX, states, tests.
Do not try to capture both repositories in one spec. The roadmap can stay shared, but each repo needs its own executable spec because the implementation surfaces, tests, and acceptance criteria are different.

View file

@ -0,0 +1,10 @@
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
public interface IConsoleLogCapture : IAsyncDisposable
{
ValueTask StartAsync(CancellationToken cancellationToken = default);
ValueTask StopAsync(CancellationToken cancellationToken = default);
ValueTask FlushIdleAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,6 @@
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
public interface IConsoleLogDroppedLineReporter
{
void ReportDropped(ConsoleLogDroppedSummary summary);
}

View file

@ -0,0 +1,12 @@
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
public interface IConsoleLogProvider
{
ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default);
ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default);
IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,8 @@
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
public interface IConsoleLogRedactor
{
ConsoleLogLine Redact(ConsoleLogLine line);
ConsoleLogSource Redact(ConsoleLogSource source);
}

View file

@ -0,0 +1,12 @@
namespace Elsa.Diagnostics.ConsoleLogs.Contracts;
public interface IConsoleLogSourceRegistry
{
event Action<ConsoleLogSource>? SourceChanged;
ConsoleLogSource Current { get; }
void MarkSeen(string sourceId, DateTimeOffset timestamp);
IReadOnlyCollection<ConsoleLogSource> List();
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides live raw console log streaming for Elsa hosts.
</Description>
<PackageTags>elsa module diagnostics console-logs stdout stderr signalr operations</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,21 @@
using Elsa.Abstractions;
using Elsa.Diagnostics.ConsoleLogs.Permissions;
using JetBrains.Annotations;
namespace Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent;
[PublicAPI]
internal class Endpoint(IConsoleLogProvider provider) : ElsaEndpoint<ConsoleLogFilter, RecentConsoleLogsResult>
{
public override void Configure()
{
Verbs(FastEndpoints.Http.POST);
Routes("/diagnostics/console-logs/recent");
ConfigurePermissions(ConsoleLogsPermissions.Read);
}
public override async Task<RecentConsoleLogsResult> ExecuteAsync(ConsoleLogFilter request, CancellationToken cancellationToken)
{
return await provider.GetRecentAsync(request, cancellationToken);
}
}

View file

@ -0,0 +1,20 @@
using Elsa.Abstractions;
using Elsa.Diagnostics.ConsoleLogs.Permissions;
using JetBrains.Annotations;
namespace Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Sources;
[PublicAPI]
internal class Endpoint(IConsoleLogProvider provider) : ElsaEndpointWithoutRequest<IReadOnlyCollection<ConsoleLogSource>>
{
public override void Configure()
{
Get("/diagnostics/console-logs/sources");
ConfigurePermissions(ConsoleLogsPermissions.Read);
}
public override async Task<IReadOnlyCollection<ConsoleLogSource>> ExecuteAsync(CancellationToken cancellationToken)
{
return await provider.ListSourcesAsync(cancellationToken);
}
}

View file

@ -0,0 +1,14 @@
using Elsa.Diagnostics.ConsoleLogs.Extensions;
using Microsoft.AspNetCore.Routing;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
public static class ConsoleLogsApplicationBuilderExtensions
{
public static IEndpointRouteBuilder UseConsoleLogs(this IEndpointRouteBuilder endpoints)
{
endpoints.MapConsoleLogsHub();
return endpoints;
}
}

View file

@ -0,0 +1,16 @@
using Elsa.Diagnostics.ConsoleLogs.Permissions;
using Elsa.Diagnostics.ConsoleLogs.RealTime;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
namespace Elsa.Diagnostics.ConsoleLogs.Extensions;
public static class EndpointRouteBuilderExtensions
{
public const string HubRoute = "/elsa/hubs/diagnostics/console-logs";
public static void MapConsoleLogsHub(this IEndpointRouteBuilder endpoints)
{
endpoints.MapHub<ConsoleLogsHub>(HubRoute).RequireAuthorization(ConsoleLogsPermissions.Read);
}
}

View file

@ -0,0 +1,13 @@
using Elsa.Diagnostics.ConsoleLogs.Features;
using Elsa.Features.Services;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
public static class ModuleExtensions
{
public static IModule UseConsoleLogs(this IModule module, Action<ConsoleLogsFeature>? configure = null)
{
return module.Use(configure);
}
}

View file

@ -0,0 +1,28 @@
using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
using Elsa.Diagnostics.ConsoleLogs.RealTime;
using Elsa.Diagnostics.ConsoleLogs.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Elsa.Diagnostics.ConsoleLogs.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddConsoleLogsServices(this IServiceCollection services, Action<ConsoleLogsOptions>? configureOptions = null)
{
if (configureOptions != null)
services.Configure(configureOptions);
services.AddSignalR();
services.AddOptions<ConsoleLogsOptions>();
services.TryAddSingleton<IConsoleLogSourceRegistry, ConsoleLogSourceRegistry>();
services.TryAddSingleton<IConsoleLogRedactor, ConsoleLogRedactor>();
services.TryAddSingleton<ConsoleLineFormatter>();
services.TryAddSingleton<IConsoleLogProvider, InMemoryConsoleLogProvider>();
services.TryAddSingleton<ConsoleLogSubscriptionManager>();
services.TryAddSingleton<IConsoleLogCapture, ConsoleCaptureTee>();
services.AddHostedService<ConsoleLogCaptureHostedService>();
return services;
}
}

View file

@ -0,0 +1,22 @@
using Elsa.Diagnostics.ConsoleLogs.Extensions;
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
namespace Elsa.Diagnostics.ConsoleLogs.Features;
public class ConsoleLogsFeature(IModule module) : FeatureBase(module)
{
public Action<ConsoleLogsOptions>? ConfigureOptions { get; set; }
public override void Configure()
{
Module.AddFastEndpointsAssembly<ConsoleLogsFeature>();
}
public override void Apply()
{
Services.AddConsoleLogsServices(ConfigureOptions);
Module.AddFastEndpointsFromModule();
}
}

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

@ -0,0 +1,9 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public record ConsoleLogDroppedSummary(
string? SourceId,
ConsoleLogStream? Stream,
string Reason,
long Count,
DateTimeOffset? From = null,
DateTimeOffset? To = null);

View file

@ -0,0 +1,11 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public record ConsoleLogFilter
{
public string? SourceId { get; init; }
public ConsoleLogStream? Stream { get; init; }
public string? Query { get; init; }
public DateTimeOffset? From { get; init; }
public DateTimeOffset? To { get; init; }
public int? Limit { get; init; }
}

View file

@ -0,0 +1,14 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public record ConsoleLogLine
{
public string Id { get; init; } = Guid.NewGuid().ToString("N");
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset ReceivedAt { get; init; } = DateTimeOffset.UtcNow;
public long Sequence { get; init; }
public ConsoleLogStream Stream { get; init; }
public string Text { get; init; } = string.Empty;
public ConsoleLogSource Source { get; init; } = default!;
public bool Truncated { get; init; }
public ConsoleLogDroppedSummary? Dropped { get; init; }
}

View file

@ -0,0 +1,18 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public record ConsoleLogSource
{
public string Id { get; init; } = default!;
public string DisplayName { get; init; } = default!;
public string? ServiceName { get; init; }
public int ProcessId { get; init; } = Environment.ProcessId;
public string MachineName { get; init; } = Environment.MachineName;
public string? PodName { get; init; }
public string? ContainerName { get; init; }
public string? Namespace { get; init; }
public string? NodeName { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? LastSeen { get; init; }
public ConsoleLogSourceHealth Health { get; init; } = ConsoleLogSourceHealth.Unknown;
public IDictionary<string, string?> Metadata { get; init; } = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}

View file

@ -0,0 +1,9 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public enum ConsoleLogSourceHealth
{
Unknown = 0,
Connected = 1,
Stale = 2,
Disconnected = 3
}

View file

@ -0,0 +1,7 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public enum ConsoleLogStream
{
Stdout = 0,
Stderr = 1
}

View file

@ -0,0 +1,13 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public record ConsoleLogStreamItem(
ConsoleLogLine? Line = null,
ConsoleLogDroppedSummary? DroppedLines = null,
ConsoleLogSource? Source = null)
{
public static ConsoleLogStreamItem FromLine(ConsoleLogLine line) => new(Line: line);
public static ConsoleLogStreamItem FromDroppedLines(ConsoleLogDroppedSummary summary) => new(DroppedLines: summary);
public static ConsoleLogStreamItem FromSource(ConsoleLogSource source) => new(Source: source);
}

View file

@ -0,0 +1,6 @@
namespace Elsa.Diagnostics.ConsoleLogs.Models;
public record RecentConsoleLogsResult(
IReadOnlyCollection<ConsoleLogLine> Items,
IReadOnlyCollection<ConsoleLogDroppedSummary>? Dropped = null,
IReadOnlyCollection<ConsoleLogSource>? Sources = null);

View file

@ -0,0 +1,35 @@
namespace Elsa.Diagnostics.ConsoleLogs.Options;
public class ConsoleLogsOptions
{
public int RecentLogCapacity { get; set; } = 5_000;
public int SubscriberChannelCapacity { get; set; } = 1_000;
public int CaptureChannelCapacity { get; set; } = 5_000;
public int MaxRecentQuerySize { get; set; } = 1_000;
public int MaxLineLength { get; set; } = 16_384;
public TimeSpan IdleFlushTimeout { get; set; } = TimeSpan.FromSeconds(1);
public bool StripAnsiEscapeSequences { get; set; } = true;
public TimeSpan SourceHeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(30);
public bool IncludeConsoleLogsInternalLogs { get; set; }
public string RedactionReplacement { get; set; } = "[Redacted]";
public ICollection<string> SensitiveNames { get; set; } =
[
"authorization",
"token",
"password",
"secret",
"api-key",
"apikey",
"cookie",
"connection-string",
"connectionstring"
];
public ICollection<string> SensitiveTextPatterns { get; set; } =
[
"(?i)bearer\\s+[A-Za-z0-9._~+/=-]+",
"(?i)(password|secret|token|api[-_]?key)\\s*[=:]\\s*[^\\s,;]+",
"(?i)(AccountKey|SharedAccessKey)=([^;\\s]+)"
];
}

View file

@ -0,0 +1,6 @@
namespace Elsa.Diagnostics.ConsoleLogs.Permissions;
public static class ConsoleLogsPermissions
{
public const string Read = "read:diagnostics:console-logs";
}

View file

@ -0,0 +1,177 @@
using System.Threading.Channels;
using Elsa.Diagnostics.ConsoleLogs.Services;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
public class InMemoryConsoleLogProvider(IOptions<ConsoleLogsOptions> options, IConsoleLogSourceRegistry sourceRegistry) : IConsoleLogProvider, IConsoleLogDroppedLineReporter
{
private readonly ConsoleLogsOptions _options = options.Value;
private readonly RingBuffer<ConsoleLogLine> _recentLines = new(options.Value.RecentLogCapacity);
private readonly object _subscribersLock = new();
private readonly Dictionary<Guid, ConsoleLogSubscriber> _subscribers = new();
private readonly object _droppedLock = new();
private readonly Dictionary<(string? SourceId, ConsoleLogStream? Stream, string Reason), long> _dropped = new();
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
{
_recentLines.Add(line);
sourceRegistry.MarkSeen(line.Source.Id, line.ReceivedAt);
List<ConsoleLogSubscriber> subscribers;
lock (_subscribersLock)
subscribers = _subscribers.Values.ToList();
foreach (var subscriber in subscribers)
subscriber.TryWrite(line, _options.SubscriberChannelCapacity);
return ValueTask.CompletedTask;
}
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default)
{
var limit = Math.Clamp(filter.Limit ?? _options.MaxRecentQuerySize, 0, _options.MaxRecentQuerySize);
var items = _recentLines
.Snapshot()
.Where(x => ConsoleLogFilterEvaluator.Matches(x, filter))
.OrderBy(x => x.ReceivedAt)
.ThenBy(x => x.Timestamp)
.ThenBy(x => x.Source.Id, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Sequence)
.ThenBy(x => x.Id, StringComparer.Ordinal)
.TakeLast(limit)
.ToList();
var dropped = ConsumeDroppedSummaries();
return ValueTask.FromResult(new RecentConsoleLogsResult(items, dropped));
}
public async IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var subscriberId = Guid.NewGuid();
var subscriber = new ConsoleLogSubscriber(filter);
lock (_subscribersLock)
_subscribers[subscriberId] = subscriber;
try
{
await foreach (var item in subscriber.Channel.Reader.ReadAllAsync(cancellationToken))
{
subscriber.MarkConsumed(item);
yield return item;
}
}
finally
{
lock (_subscribersLock)
_subscribers.Remove(subscriberId);
}
}
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(sourceRegistry.List());
}
public void ReportDropped(ConsoleLogDroppedSummary summary)
{
lock (_droppedLock)
{
var key = (summary.SourceId, summary.Stream, summary.Reason);
_dropped[key] = _dropped.GetValueOrDefault(key) + summary.Count;
}
List<ConsoleLogSubscriber> subscribers;
lock (_subscribersLock)
subscribers = _subscribers.Values.ToList();
foreach (var subscriber in subscribers)
subscriber.TryWrite(summary);
}
private IReadOnlyCollection<ConsoleLogDroppedSummary> ConsumeDroppedSummaries()
{
var summaries = new List<ConsoleLogDroppedSummary>();
var recentDroppedCount = _recentLines.ConsumeDroppedCount();
if (recentDroppedCount > 0)
summaries.Add(new ConsoleLogDroppedSummary(null, null, "RecentBufferFull", recentDroppedCount));
lock (_droppedLock)
{
summaries.AddRange(_dropped.Select(x => new ConsoleLogDroppedSummary(x.Key.SourceId, x.Key.Stream, x.Key.Reason, x.Value)));
_dropped.Clear();
}
return summaries;
}
private sealed class ConsoleLogSubscriber(ConsoleLogFilter filter)
{
private readonly object _lock = new();
private int _pendingItemCount;
private long _droppedSinceLastSummary;
private bool _summaryQueued;
public Channel<ConsoleLogStreamItem> Channel { get; } = System.Threading.Channels.Channel.CreateUnbounded<ConsoleLogStreamItem>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
public void TryWrite(ConsoleLogLine line, int capacity)
{
if (!ConsoleLogFilterEvaluator.Matches(line, filter))
return;
lock (_lock)
{
if (_pendingItemCount >= capacity)
{
_droppedSinceLastSummary++;
QueueDroppedSummaryIfNeeded(line);
return;
}
Channel.Writer.TryWrite(ConsoleLogStreamItem.FromLine(line));
_pendingItemCount++;
}
}
public void TryWrite(ConsoleLogDroppedSummary summary)
{
lock (_lock)
{
Channel.Writer.TryWrite(ConsoleLogStreamItem.FromDroppedLines(summary));
_pendingItemCount++;
}
}
public void MarkConsumed(ConsoleLogStreamItem item)
{
lock (_lock)
{
_pendingItemCount = Math.Max(0, _pendingItemCount - 1);
if (item.DroppedLines != null)
{
_summaryQueued = false;
QueueDroppedSummaryIfNeeded(item.Line);
}
}
}
private void QueueDroppedSummaryIfNeeded(ConsoleLogLine? line)
{
if (_summaryQueued || _droppedSinceLastSummary == 0)
return;
var summary = new ConsoleLogDroppedSummary(line?.Source.Id, line?.Stream, "SubscriberChannelFull", _droppedSinceLastSummary);
_droppedSinceLastSummary = 0;
_summaryQueued = true;
_pendingItemCount++;
Channel.Writer.TryWrite(ConsoleLogStreamItem.FromDroppedLines(summary));
}
}
}

View file

@ -0,0 +1,48 @@
namespace Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
public class RingBuffer<T>
{
private readonly Queue<T> _items = new();
private readonly object _lock = new();
private readonly int _capacity;
public RingBuffer(int capacity)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be greater than zero.");
_capacity = capacity;
}
public long DroppedCount { get; private set; }
public void Add(T item)
{
lock (_lock)
{
if (_items.Count == _capacity)
{
_items.Dequeue();
DroppedCount++;
}
_items.Enqueue(item);
}
}
public IReadOnlyCollection<T> Snapshot()
{
lock (_lock)
return _items.ToList();
}
public long ConsumeDroppedCount()
{
lock (_lock)
{
var droppedCount = DroppedCount;
DroppedCount = 0;
return droppedCount;
}
}
}

View file

@ -0,0 +1,51 @@
# Elsa Diagnostics Console Logs
`Elsa.Diagnostics.ConsoleLogs` is an opt-in Core module for operational diagnostics. It captures raw backend `stdout` and `stderr` lines, preserves the host's original console destinations, redacts data before provider boundaries, and exposes recent plus live console output to authorized callers.
## What It Captures
- Raw `stdout` and `stderr` lines from the current backend process.
- Source metadata for the current process, machine, and container environment when available.
- Recent bounded history and live SignalR events.
- Dropped-line summaries when recent buffers or subscriber queues overflow.
The module is separate from `Elsa.Diagnostics.StructuredLogs`. It does not parse `ILogger` records, provide durable audit storage, call orchestrator log APIs, or implement trace/metric exploration.
## Configure
```csharp
services.AddElsa(elsa =>
{
elsa.UseConsoleLogs(options =>
{
options.RecentLogCapacity = 5_000;
options.SubscriberChannelCapacity = 1_000;
options.MaxRecentQuerySize = 1_000;
options.MaxLineLength = 16_384;
options.StripAnsiEscapeSequences = true;
});
});
```
Map the live hub:
```csharp
app.UseConsoleLogs();
```
## Contracts
- Recent lines: `POST /diagnostics/console-logs/recent`
- Sources: `GET /diagnostics/console-logs/sources`
- Live hub: `/elsa/hubs/diagnostics/console-logs`
- Permission: `read:diagnostics:console-logs`
Recent, source, and hub access all require the same permission.
## Safety Boundaries
- Redaction runs before recent buffering, live streaming, endpoint responses, and provider storage.
- ANSI escape sequences are stripped by default.
- Partial writes are buffered until newline, max line length, or idle flush.
- Oversized lines are truncated to one event and marked as truncated.
- Providers receive only redacted line text and redacted source metadata.

View file

@ -0,0 +1,142 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace Elsa.Diagnostics.ConsoleLogs.RealTime;
public class ConsoleLogSubscriptionManager : IDisposable
{
private readonly ConcurrentDictionary<string, ConsoleLogSubscription> _subscriptions = new(StringComparer.Ordinal);
private readonly IConsoleLogProvider _provider;
private readonly IConsoleLogSourceRegistry _sourceRegistry;
private readonly IHubContext<ConsoleLogsHub, IConsoleLogsClient> _hubContext;
private readonly ILogger<ConsoleLogSubscriptionManager> _logger;
public ConsoleLogSubscriptionManager(
IConsoleLogProvider provider,
IConsoleLogSourceRegistry sourceRegistry,
IHubContext<ConsoleLogsHub, IConsoleLogsClient> hubContext,
ILogger<ConsoleLogSubscriptionManager> logger)
{
_provider = provider;
_sourceRegistry = sourceRegistry;
_hubContext = hubContext;
_logger = logger;
_sourceRegistry.SourceChanged += OnSourceChanged;
}
public Task SubscribeAsync(string connectionId, ConsoleLogFilter filter, CancellationToken cancellationToken)
{
Unsubscribe(connectionId);
var subscriptionCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var subscription = new ConsoleLogSubscription(filter, subscriptionCancellation);
_subscriptions[connectionId] = subscription;
_ = StreamAsync(connectionId, filter, subscription, subscriptionCancellation.Token);
return Task.CompletedTask;
}
public Task UpdateFilterAsync(string connectionId, ConsoleLogFilter filter, CancellationToken cancellationToken)
{
return SubscribeAsync(connectionId, filter, cancellationToken);
}
public Task UnsubscribeAsync(string connectionId)
{
Unsubscribe(connectionId);
return Task.CompletedTask;
}
public void Dispose()
{
_sourceRegistry.SourceChanged -= OnSourceChanged;
foreach (var subscription in _subscriptions.Values)
{
subscription.CancellationTokenSource.Cancel();
subscription.CancellationTokenSource.Dispose();
}
_subscriptions.Clear();
}
private async Task StreamAsync(string connectionId, ConsoleLogFilter filter, ConsoleLogSubscription subscription, CancellationToken cancellationToken)
{
try
{
await foreach (var item in _provider.SubscribeAsync(filter, cancellationToken))
{
if (item.Line != null)
await _hubContext.Clients.Client(connectionId).ReceiveConsoleLogLineAsync(item.Line, cancellationToken);
if (item.DroppedLines != null)
await _hubContext.Clients.Client(connectionId).ReceiveDroppedLinesAsync(item.DroppedLines, cancellationToken);
if (item.Source != null)
await _hubContext.Clients.Client(connectionId).ReceiveSourceChangedAsync(item.Source, cancellationToken);
}
}
catch (OperationCanceledException e)
{
_logger.LogDebug(e, "Console log subscription for connection {ConnectionId} was canceled", connectionId);
}
catch (Exception e) when (e is not OperationCanceledException)
{
_logger.LogWarning(e, "Console log subscription for connection {ConnectionId} stopped unexpectedly", connectionId);
}
finally
{
Remove(connectionId, subscription);
}
}
private void Unsubscribe(string connectionId)
{
if (!_subscriptions.TryRemove(connectionId, out var subscription))
return;
subscription.CancellationTokenSource.Cancel();
subscription.CancellationTokenSource.Dispose();
}
private void Remove(string connectionId, ConsoleLogSubscription subscription)
{
var entry = new KeyValuePair<string, ConsoleLogSubscription>(connectionId, subscription);
if (((ICollection<KeyValuePair<string, ConsoleLogSubscription>>)_subscriptions).Remove(entry))
subscription.CancellationTokenSource.Dispose();
}
private void OnSourceChanged(ConsoleLogSource source)
{
_ = BroadcastSourceChangedAsync(source, _subscriptions.ToArray());
}
private async Task BroadcastSourceChangedAsync(ConsoleLogSource source, IReadOnlyCollection<KeyValuePair<string, ConsoleLogSubscription>> subscriptions)
{
try
{
foreach (var (connectionId, subscription) in subscriptions)
{
if (!MatchesSource(source, subscription.Filter))
continue;
await _hubContext.Clients.Client(connectionId).ReceiveSourceChangedAsync(source, subscription.CancellationTokenSource.Token);
}
}
catch (OperationCanceledException e)
{
_logger.LogDebug(e, "Console log source change broadcast for source {SourceId} was canceled", source.Id);
}
catch (Exception e) when (e is not OperationCanceledException)
{
_logger.LogDebug(e, "Failed to broadcast console log source change for source {SourceId}", source.Id);
}
}
private static bool MatchesSource(ConsoleLogSource source, ConsoleLogFilter filter)
{
return string.IsNullOrWhiteSpace(filter.SourceId) || string.Equals(source.Id, filter.SourceId, StringComparison.OrdinalIgnoreCase);
}
private sealed record ConsoleLogSubscription(ConsoleLogFilter Filter, CancellationTokenSource CancellationTokenSource);
}

View file

@ -0,0 +1,37 @@
using Elsa.Diagnostics.ConsoleLogs.Permissions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Elsa.Diagnostics.ConsoleLogs.RealTime;
[Authorize(Policy = ConsoleLogsPermissions.Read)]
public class ConsoleLogsHub(ConsoleLogSubscriptionManager subscriptionManager) : Hub<IConsoleLogsClient>
{
public Task SubscribeAsync(ConsoleLogFilter? filter)
{
return subscriptionManager.SubscribeAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted);
}
public Task UpdateFilterAsync(ConsoleLogFilter? filter) => subscriptionManager.UpdateFilterAsync(Context.ConnectionId, ValidateFilter(filter), Context.ConnectionAborted);
public Task UnsubscribeAsync()
{
return subscriptionManager.UnsubscribeAsync(Context.ConnectionId);
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
await UnsubscribeAsync();
await base.OnDisconnectedAsync(exception);
}
private static ConsoleLogFilter ValidateFilter(ConsoleLogFilter? filter)
{
filter ??= new();
if (filter.From is { } from && filter.To is { } to && from > to)
throw new HubException("The console log filter 'from' timestamp must be earlier than or equal to 'to'.");
return filter;
}
}

View file

@ -0,0 +1,10 @@
namespace Elsa.Diagnostics.ConsoleLogs.RealTime;
public interface IConsoleLogsClient
{
Task ReceiveConsoleLogLineAsync(ConsoleLogLine line, CancellationToken cancellationToken = default);
Task ReceiveDroppedLinesAsync(ConsoleLogDroppedSummary summary, CancellationToken cancellationToken = default);
Task ReceiveSourceChangedAsync(ConsoleLogSource source, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,211 @@
using System.Text;
using System.Threading.Channels;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.ConsoleLogs.Services;
public class ConsoleCaptureTee(
IConsoleLogProvider provider,
IConsoleLogSourceRegistry sourceRegistry,
IConsoleLogRedactor redactor,
ConsoleLineFormatter formatter,
IOptions<ConsoleLogsOptions> options) : TextWriter, IConsoleLogCapture
{
private readonly object _lock = new();
private readonly ConsoleLogsOptions _options = options.Value;
private readonly ConsoleLineBuffer _stdoutBuffer = new(options);
private readonly ConsoleLineBuffer _stderrBuffer = new(options);
private TextWriter? _originalOut;
private TextWriter? _originalError;
private Channel<ConsoleLogLine>? _publishChannel;
private CancellationTokenSource? _publishCancellation;
private Task? _publishTask;
private long _sequence;
public override Encoding Encoding => _originalOut?.Encoding ?? Encoding.UTF8;
public ValueTask StartAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_originalOut != null || _originalError != null)
return ValueTask.CompletedTask;
_publishCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_publishChannel = Channel.CreateBounded<ConsoleLogLine>(new BoundedChannelOptions(Math.Max(1, _options.CaptureChannelCapacity))
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false
});
_publishTask = PublishQueuedLinesAsync(_publishChannel.Reader, _publishCancellation.Token);
_originalOut = Console.Out;
_originalError = Console.Error;
Console.SetOut(new TeeWriter(_originalOut, this, ConsoleLogStream.Stdout));
Console.SetError(new TeeWriter(_originalError, this, ConsoleLogStream.Stderr));
}
return ValueTask.CompletedTask;
}
public ValueTask StopAsync(CancellationToken cancellationToken = default)
{
Task? publishTask;
CancellationTokenSource? publishCancellation;
lock (_lock)
{
if (_originalOut != null)
Console.SetOut(_originalOut);
if (_originalError != null)
Console.SetError(_originalError);
FlushRemaining(ConsoleLogStream.Stdout);
FlushRemaining(ConsoleLogStream.Stderr);
_publishChannel?.Writer.TryComplete();
publishTask = _publishTask;
publishCancellation = _publishCancellation;
_originalOut = null;
_originalError = null;
_publishChannel = null;
_publishCancellation = null;
_publishTask = null;
}
return AwaitPublisherAsync(publishTask, publishCancellation, cancellationToken);
}
public override void Write(char value)
{
Capture(ConsoleLogStream.Stdout, value.ToString());
}
public override ValueTask DisposeAsync()
{
return StopAsync();
}
public ValueTask FlushIdleAsync(CancellationToken cancellationToken = default)
{
foreach (var stream in new[] { ConsoleLogStream.Stdout, ConsoleLogStream.Stderr })
{
string? line;
lock (_lock)
line = GetBuffer(stream).FlushIfIdle(DateTimeOffset.UtcNow);
if (line != null)
Publish(stream, line);
}
return ValueTask.CompletedTask;
}
private void Capture(ConsoleLogStream stream, string value)
{
IReadOnlyCollection<string> lines;
lock (_lock)
lines = GetBuffer(stream).Append(value, DateTimeOffset.UtcNow);
foreach (var line in lines)
Publish(stream, line);
}
private void FlushRemaining(ConsoleLogStream stream)
{
var line = GetBuffer(stream).Flush();
if (line != null)
Publish(stream, line);
}
private ConsoleLineBuffer GetBuffer(ConsoleLogStream stream) => stream == ConsoleLogStream.Stdout ? _stdoutBuffer : _stderrBuffer;
private void Publish(ConsoleLogStream stream, string text)
{
var formatted = formatter.Format(text);
var now = DateTimeOffset.UtcNow;
var line = new ConsoleLogLine
{
Timestamp = now,
ReceivedAt = now,
Sequence = Interlocked.Increment(ref _sequence),
Stream = stream,
Text = formatted.Text,
Source = sourceRegistry.Current,
Truncated = formatted.Truncated
};
if (_publishChannel?.Writer.TryWrite(redactor.Redact(line)) != false)
return;
if (provider is IConsoleLogDroppedLineReporter reporter)
reporter.ReportDropped(new ConsoleLogDroppedSummary(line.Source.Id, stream, "CaptureChannelFull", 1));
}
private async Task PublishQueuedLinesAsync(ChannelReader<ConsoleLogLine> reader, CancellationToken cancellationToken)
{
try
{
await foreach (var line in reader.ReadAllAsync(cancellationToken))
{
try
{
await provider.PublishAsync(line, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
// Avoid logging from the console capture path; doing so would recurse through the same tee.
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown completes or cancels the publish pump.
}
}
private static async ValueTask AwaitPublisherAsync(Task? publishTask, CancellationTokenSource? publishCancellation, CancellationToken cancellationToken)
{
if (publishTask == null)
return;
using var cancellation = publishCancellation;
try
{
await publishTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
cancellation?.Cancel();
throw;
}
}
private sealed class TeeWriter(TextWriter original, ConsoleCaptureTee capture, ConsoleLogStream stream) : TextWriter
{
public override Encoding Encoding => original.Encoding;
public override void Write(char value)
{
original.Write(value);
capture.Capture(stream, value.ToString());
}
public override void Write(string? value)
{
original.Write(value);
if (value != null)
capture.Capture(stream, value);
}
public override void Flush()
{
original.Flush();
}
}
}

View file

@ -0,0 +1,56 @@
using System.Text;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.ConsoleLogs.Services;
public class ConsoleLineBuffer(IOptions<ConsoleLogsOptions> options)
{
private readonly StringBuilder _buffer = new();
private readonly ConsoleLogsOptions _options = options.Value;
private DateTimeOffset? _lastWriteAt;
public IReadOnlyCollection<string> Append(string value, DateTimeOffset now)
{
_lastWriteAt = now;
var lines = new List<string>();
foreach (var ch in value.Where(x => x != '\r'))
{
if (ch == '\n')
{
if (_buffer.Length > 0)
lines.Add(FlushBuffer());
continue;
}
_buffer.Append(ch);
if (_buffer.Length >= _options.MaxLineLength)
lines.Add(FlushBuffer());
}
return lines;
}
public string? FlushIfIdle(DateTimeOffset now)
{
if (_buffer.Length == 0 || _lastWriteAt == null)
return null;
return now - _lastWriteAt >= _options.IdleFlushTimeout ? FlushBuffer() : null;
}
public string? Flush()
{
return _buffer.Length == 0 ? null : FlushBuffer();
}
private string FlushBuffer()
{
var line = _buffer.ToString();
_buffer.Clear();
_lastWriteAt = null;
return line;
}
}

View file

@ -0,0 +1,24 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.ConsoleLogs.Services;
public class ConsoleLineFormatter(IOptions<ConsoleLogsOptions> options)
{
private static readonly Regex AnsiRegex = new(@"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly ConsoleLogsOptions _options = options.Value;
public FormattedConsoleLine Format(string text)
{
if (_options.StripAnsiEscapeSequences)
text = AnsiRegex.Replace(text, string.Empty);
var truncated = text.Length > _options.MaxLineLength;
if (truncated)
text = text[.._options.MaxLineLength];
return new(text, truncated);
}
}
public record FormattedConsoleLine(string Text, bool Truncated);

View file

@ -0,0 +1,30 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.ConsoleLogs.Services;
public class ConsoleLogCaptureHostedService(IConsoleLogCapture capture, IOptions<ConsoleLogsOptions> options) : BackgroundService
{
public override async Task StartAsync(CancellationToken cancellationToken)
{
await capture.StartAsync(cancellationToken);
await base.StartAsync(cancellationToken);
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
await capture.StopAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var interval = TimeSpan.FromMilliseconds(Math.Max(100, options.Value.IdleFlushTimeout.TotalMilliseconds / 2));
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(interval, stoppingToken);
await capture.FlushIdleAsync(stoppingToken);
}
}
}

View file

@ -0,0 +1,41 @@
namespace Elsa.Diagnostics.ConsoleLogs.Services;
public static class ConsoleLogFilterEvaluator
{
public static bool Matches(ConsoleLogLine line, ConsoleLogFilter filter)
{
if (!EqualsFilter(line.Source.Id, filter.SourceId))
return false;
if (filter.Stream is { } stream && line.Stream != stream)
return false;
if (!ContainsText(line, filter.Query))
return false;
if (filter.From is { } from && line.ReceivedAt < from)
return false;
if (filter.To is { } to && line.ReceivedAt > to)
return false;
return true;
}
private static bool EqualsFilter(string? value, string? filter) => string.IsNullOrWhiteSpace(filter) || string.Equals(value, filter, StringComparison.OrdinalIgnoreCase);
private static bool Contains(string? value, string? filter) => string.IsNullOrWhiteSpace(filter) || value?.Contains(filter, StringComparison.OrdinalIgnoreCase) == true;
private static bool ContainsText(ConsoleLogLine line, string? filter)
{
if (string.IsNullOrWhiteSpace(filter))
return true;
return Contains(line.Text, filter)
|| Contains(line.Source.Id, filter)
|| Contains(line.Source.DisplayName, filter)
|| Contains(line.Source.ServiceName, filter)
|| Contains(line.Source.MachineName, filter)
|| line.Source.Metadata.Any(x => Contains(x.Key, filter) || Contains(x.Value, filter));
}
}

View file

@ -0,0 +1,56 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.ConsoleLogs.Services;
public class ConsoleLogRedactor(IOptions<ConsoleLogsOptions> options) : IConsoleLogRedactor
{
private readonly ConsoleLogsOptions _options = options.Value;
private readonly HashSet<string> _sensitiveNames = options.Value.SensitiveNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
private readonly IReadOnlyCollection<Regex> _sensitiveTextPatterns = options.Value.SensitiveTextPatterns
.Select(pattern => new Regex(pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant))
.ToList();
public ConsoleLogLine Redact(ConsoleLogLine line)
{
return line with
{
Text = RedactValue("text", line.Text) ?? string.Empty,
Source = Redact(line.Source)
};
}
public ConsoleLogSource Redact(ConsoleLogSource source)
{
return source with
{
Id = RedactValue("id", source.Id) ?? source.Id,
DisplayName = RedactValue("displayName", source.DisplayName) ?? source.DisplayName,
ServiceName = RedactValue("serviceName", source.ServiceName),
MachineName = RedactValue("machineName", source.MachineName) ?? source.MachineName,
PodName = RedactValue("podName", source.PodName),
ContainerName = RedactValue("containerName", source.ContainerName),
Namespace = RedactValue("namespace", source.Namespace),
NodeName = RedactValue("nodeName", source.NodeName),
Metadata = RedactDictionary(source.Metadata)
};
}
private Dictionary<string, string?> RedactDictionary(IDictionary<string, string?> values)
{
return values.ToDictionary(x => x.Key, x => RedactValue(x.Key, x.Value), StringComparer.OrdinalIgnoreCase);
}
private string? RedactValue(string name, string? value)
{
if (value == null)
return null;
if (IsSensitiveName(name))
return _options.RedactionReplacement;
return _sensitiveTextPatterns.Aggregate(value, (current, pattern) => pattern.Replace(current, _options.RedactionReplacement));
}
private bool IsSensitiveName(string name) => _sensitiveNames.Any(sensitiveName => name.Contains(sensitiveName, StringComparison.OrdinalIgnoreCase));
}

View file

@ -0,0 +1,106 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.ConsoleLogs.Services;
public class ConsoleLogSourceRegistry : IConsoleLogSourceRegistry
{
private readonly ConcurrentDictionary<string, ConsoleLogSource> _sources = new();
private readonly ConsoleLogsOptions _options;
public ConsoleLogSourceRegistry(IOptions<ConsoleLogsOptions> options)
{
_options = options.Value;
Current = CreateCurrentSource();
_sources[Current.Id] = Current;
}
public event Action<ConsoleLogSource>? SourceChanged;
public ConsoleLogSource Current { get; }
public void MarkSeen(string sourceId, DateTimeOffset timestamp)
{
while (true)
{
if (_sources.TryGetValue(sourceId, out var existing))
{
var current = ApplyCurrentHealth(existing, DateTimeOffset.UtcNow);
var updated = current with { LastSeen = timestamp, Health = ConsoleLogSourceHealth.Connected };
if (!_sources.TryUpdate(sourceId, updated, existing))
continue;
if (current.Health != updated.Health)
SourceChanged?.Invoke(updated);
return;
}
var source = new ConsoleLogSource
{
Id = sourceId,
DisplayName = sourceId,
MachineName = "",
ProcessId = 0,
LastSeen = timestamp,
Health = ConsoleLogSourceHealth.Connected
};
if (!_sources.TryAdd(sourceId, source))
continue;
SourceChanged?.Invoke(source);
return;
}
}
public IReadOnlyCollection<ConsoleLogSource> List()
{
var now = DateTimeOffset.UtcNow;
return _sources
.Select(entry => RefreshHealth(entry.Key, entry.Value, now))
.OrderBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private ConsoleLogSource RefreshHealth(string sourceId, ConsoleLogSource source, DateTimeOffset now)
{
var updated = ApplyCurrentHealth(source, now);
if (updated.Health == source.Health)
return source;
if (!_sources.TryUpdate(sourceId, updated, source))
return source;
SourceChanged?.Invoke(updated);
return updated;
}
private ConsoleLogSource ApplyCurrentHealth(ConsoleLogSource source, DateTimeOffset now)
{
var staleBefore = now.Subtract(_options.SourceHeartbeatTimeout);
return source.LastSeen < staleBefore ? source with { Health = ConsoleLogSourceHealth.Stale } : source;
}
private static ConsoleLogSource CreateCurrentSource()
{
var podName = Environment.GetEnvironmentVariable("HOSTNAME");
var serviceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ?? AppDomain.CurrentDomain.FriendlyName;
var sourceId = $"{Environment.MachineName}-{Environment.ProcessId}";
var displayName = !string.IsNullOrWhiteSpace(podName) ? podName : sourceId;
return new()
{
Id = sourceId,
DisplayName = displayName,
ServiceName = serviceName,
PodName = podName,
Namespace = Environment.GetEnvironmentVariable("POD_NAMESPACE"),
ContainerName = Environment.GetEnvironmentVariable("CONTAINER_NAME"),
NodeName = Environment.GetEnvironmentVariable("NODE_NAME"),
StartedAt = DateTimeOffset.UtcNow,
LastSeen = DateTimeOffset.UtcNow,
Health = ConsoleLogSourceHealth.Connected
};
}
}

View file

@ -0,0 +1,62 @@
using CShells.AspNetCore.Features;
using CShells.FastEndpoints.Features;
using CShells.Features;
using Elsa.Diagnostics.ConsoleLogs.Extensions;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Elsa.Diagnostics.ConsoleLogs.ShellFeatures;
/// <summary>
/// Provides live raw console log streaming over REST and SignalR.
/// </summary>
[ShellFeature(
DisplayName = "Console Logs",
Description = "Provides live raw console log streaming over REST and SignalR",
DependsOn = ["ElsaFastEndpoints"])]
[UsedImplicitly]
public class ConsoleLogsFeature : IFastEndpointsShellFeature, IWebShellFeature
{
private static readonly ConsoleLogsOptions DefaultOptions = new();
public int RecentLogCapacity { get; set; } = DefaultOptions.RecentLogCapacity;
public int SubscriberChannelCapacity { get; set; } = DefaultOptions.SubscriberChannelCapacity;
public int CaptureChannelCapacity { get; set; } = DefaultOptions.CaptureChannelCapacity;
public int MaxRecentQuerySize { get; set; } = DefaultOptions.MaxRecentQuerySize;
public int MaxLineLength { get; set; } = DefaultOptions.MaxLineLength;
public TimeSpan IdleFlushTimeout { get; set; } = DefaultOptions.IdleFlushTimeout;
public bool StripAnsiEscapeSequences { get; set; } = DefaultOptions.StripAnsiEscapeSequences;
public TimeSpan SourceHeartbeatTimeout { get; set; } = DefaultOptions.SourceHeartbeatTimeout;
public bool IncludeConsoleLogsInternalLogs { get; set; } = DefaultOptions.IncludeConsoleLogsInternalLogs;
public string RedactionReplacement { get; set; } = DefaultOptions.RedactionReplacement;
public ICollection<string> SensitiveNames { get; set; } = [..DefaultOptions.SensitiveNames];
public ICollection<string> SensitiveTextPatterns { get; set; } = [..DefaultOptions.SensitiveTextPatterns];
public void ConfigureServices(IServiceCollection services)
{
services.AddConsoleLogsServices(ConfigureOptions);
}
public void MapEndpoints(IEndpointRouteBuilder endpoints, IHostEnvironment? environment)
{
endpoints.MapConsoleLogsHub();
}
private void ConfigureOptions(ConsoleLogsOptions options)
{
options.RecentLogCapacity = RecentLogCapacity;
options.SubscriberChannelCapacity = SubscriberChannelCapacity;
options.CaptureChannelCapacity = CaptureChannelCapacity;
options.MaxRecentQuerySize = MaxRecentQuerySize;
options.MaxLineLength = MaxLineLength;
options.IdleFlushTimeout = IdleFlushTimeout;
options.StripAnsiEscapeSequences = StripAnsiEscapeSequences;
options.SourceHeartbeatTimeout = SourceHeartbeatTimeout;
options.IncludeConsoleLogsInternalLogs = IncludeConsoleLogsInternalLogs;
options.RedactionReplacement = RedactionReplacement;
options.SensitiveNames = [..SensitiveNames];
options.SensitiveTextPatterns = [..SensitiveTextPatterns];
}
}

View file

@ -0,0 +1,4 @@
global using System.Runtime.CompilerServices;
global using Elsa.Diagnostics.ConsoleLogs.Contracts;
global using Elsa.Diagnostics.ConsoleLogs.Models;
global using Elsa.Diagnostics.ConsoleLogs.Options;

View file

@ -0,0 +1,16 @@
using Elsa.Diagnostics.ConsoleLogs.Permissions;
using Elsa.Diagnostics.ConsoleLogs.RealTime;
using Microsoft.AspNetCore.Authorization;
namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests;
public class ConsoleLogsAuthorizationTests
{
[Fact]
public void HubAuthorization_UsesDedicatedPermission()
{
var authorize = Assert.Single(typeof(ConsoleLogsHub).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true).Cast<AuthorizeAttribute>());
Assert.Equal(ConsoleLogsPermissions.Read, authorize.Policy);
}
}

View file

@ -0,0 +1,12 @@
using Elsa.Diagnostics.ConsoleLogs.Permissions;
namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests;
public class ConsoleLogsHubAuthorizationTests
{
[Fact]
public void Permission_AppliesToHubAndEndpointContract()
{
Assert.Equal("read:diagnostics:console-logs", ConsoleLogsPermissions.Read);
}
}

View file

@ -0,0 +1,12 @@
using Elsa.Diagnostics.ConsoleLogs.RealTime;
namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests;
public class ConsoleLogsHubSourceStatusTests
{
[Fact]
public void ClientContract_ExposesSourceChangeMethod()
{
Assert.NotNull(typeof(IConsoleLogsClient).GetMethod(nameof(IConsoleLogsClient.ReceiveSourceChangedAsync)));
}
}

View file

@ -0,0 +1,14 @@
using Elsa.Diagnostics.ConsoleLogs.RealTime;
namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests;
public class ConsoleLogsHubTests
{
[Fact]
public void Hub_ExposesSubscriptionMethods()
{
Assert.NotNull(typeof(ConsoleLogsHub).GetMethod(nameof(ConsoleLogsHub.SubscribeAsync)));
Assert.NotNull(typeof(ConsoleLogsHub).GetMethod(nameof(ConsoleLogsHub.UpdateFilterAsync)));
Assert.NotNull(typeof(ConsoleLogsHub).GetMethod(nameof(ConsoleLogsHub.UnsubscribeAsync)));
}
}

View file

@ -0,0 +1,39 @@
using Elsa.Diagnostics.ConsoleLogs.Extensions;
using Elsa.Diagnostics.ConsoleLogs.Features;
using Elsa.Diagnostics.ConsoleLogs.Permissions;
using Elsa.Diagnostics.ConsoleLogs.RealTime;
using Microsoft.AspNetCore.Authorization;
namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests;
public class ConsoleLogsModuleTests
{
[Fact]
public void Module_UsesDiagnosticsConsoleLogsIdentity()
{
Assert.Equal("/elsa/hubs/diagnostics/console-logs", EndpointRouteBuilderExtensions.HubRoute);
Assert.Equal("read:diagnostics:console-logs", ConsoleLogsPermissions.Read);
Assert.StartsWith("Elsa.Diagnostics.ConsoleLogs", typeof(ConsoleLogsFeature).Namespace);
}
[Fact]
public void Hub_RequiresConsoleLogsPermission()
{
var authorize = Assert.Single(typeof(ConsoleLogsHub).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true).Cast<AuthorizeAttribute>());
Assert.Equal(ConsoleLogsPermissions.Read, authorize.Policy);
}
[Fact]
public void ConsoleLogsAssembly_DoesNotReferenceStructuredLogsOrExternalProviders()
{
var references = typeof(ConsoleLogsFeature)
.Assembly
.GetReferencedAssemblies()
.Select(x => x.Name)
.ToList();
Assert.DoesNotContain("Elsa.Diagnostics.StructuredLogs", references);
Assert.DoesNotContain("Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite", references);
}
}

View file

@ -0,0 +1,14 @@
namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests;
public class ConsoleLogsRecentEndpointTests
{
[Fact]
public void RecentEndpoint_ExistsInConsoleLogsAssembly()
{
var endpointType = typeof(Elsa.Diagnostics.ConsoleLogs.Features.ConsoleLogsFeature)
.Assembly
.GetType("Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Recent.Endpoint");
Assert.NotNull(endpointType);
}
}

View file

@ -0,0 +1,14 @@
namespace Elsa.Diagnostics.ConsoleLogs.IntegrationTests;
public class ConsoleLogsSourcesEndpointTests
{
[Fact]
public void SourcesEndpoint_ExistsInConsoleLogsAssembly()
{
var endpointType = typeof(Elsa.Diagnostics.ConsoleLogs.Features.ConsoleLogsFeature)
.Assembly
.GetType("Elsa.Diagnostics.ConsoleLogs.Endpoints.ConsoleLogs.Sources.Endpoint");
Assert.NotNull(endpointType);
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.Diagnostics.ConsoleLogs]*</Include>
<Threshold>0</Threshold>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared.Integration\Elsa.Testing.Shared.Integration.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Diagnostics.ConsoleLogs\Elsa.Diagnostics.ConsoleLogs.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1 @@
global using Xunit;

View file

@ -0,0 +1,3 @@
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View file

@ -0,0 +1,105 @@
using Elsa.Diagnostics.ConsoleLogs.Contracts;
using Elsa.Diagnostics.ConsoleLogs.Services;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Capture;
public class ConsoleCaptureTeeTests
{
[Fact]
public async Task StartAsync_PreservesOriginalConsoleOutputAndPublishesLine()
{
var originalOut = Console.Out;
var originalError = Console.Error;
using var consoleOutput = new StringWriter();
var provider = new CapturingProvider();
var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()));
var options = Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions());
var capture = new ConsoleCaptureTee(provider, registry, new ConsoleLogRedactor(options), new ConsoleLineFormatter(options), options);
try
{
Console.SetOut(consoleOutput);
await capture.StartAsync();
Console.WriteLine("hello");
await WaitForLineAsync(provider);
Assert.Equal($"hello{Environment.NewLine}", consoleOutput.ToString());
Assert.Single(provider.Lines);
Assert.Equal("hello", provider.Lines[0].Text);
Assert.Equal(ConsoleLogStream.Stdout, provider.Lines[0].Stream);
}
finally
{
await capture.StopAsync();
Console.SetOut(originalOut);
Console.SetError(originalError);
}
}
[Fact]
public async Task StartAsync_WhenAlreadyStarted_DoesNotWrapConsoleTwice()
{
var originalOut = Console.Out;
var originalError = Console.Error;
using var consoleOutput = new StringWriter();
var provider = new CapturingProvider();
var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()));
var options = Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions());
var capture = new ConsoleCaptureTee(provider, registry, new ConsoleLogRedactor(options), new ConsoleLineFormatter(options), options);
try
{
Console.SetOut(consoleOutput);
await capture.StartAsync();
await capture.StartAsync();
Console.WriteLine("hello");
await WaitForLineAsync(provider);
Assert.Equal($"hello{Environment.NewLine}", consoleOutput.ToString());
Assert.Single(provider.Lines);
}
finally
{
await capture.StopAsync();
Console.SetOut(originalOut);
Console.SetError(originalError);
}
}
private static async Task WaitForLineAsync(CapturingProvider provider)
{
var timeout = DateTimeOffset.UtcNow.AddSeconds(2);
while (provider.Lines.Count == 0 && DateTimeOffset.UtcNow < timeout)
await Task.Delay(10);
}
private sealed class CapturingProvider : IConsoleLogProvider
{
public List<ConsoleLogLine> Lines { get; } = [];
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
{
Lines.Add(line);
return ValueTask.CompletedTask;
}
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(new RecentConsoleLogsResult(Lines));
}
public async IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
foreach (var line in Lines)
yield return ConsoleLogStreamItem.FromLine(line);
}
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult<IReadOnlyCollection<ConsoleLogSource>>([]);
}
}
}

View file

@ -0,0 +1,38 @@
using Elsa.Diagnostics.ConsoleLogs.Services;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Capture;
public class ConsoleLineBufferTests
{
private readonly ConsoleLineBuffer _buffer = new(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { MaxLineLength = 5, IdleFlushTimeout = TimeSpan.FromSeconds(1) }));
[Fact]
public void Append_BuffersPartialWritesUntilNewline()
{
Assert.Empty(_buffer.Append("hel", DateTimeOffset.UtcNow));
var lines = _buffer.Append("lo\n", DateTimeOffset.UtcNow);
Assert.Equal(["hello"], lines);
}
[Fact]
public void Append_CompletesLineAtMaximumLength()
{
var lines = _buffer.Append("hello!", DateTimeOffset.UtcNow);
Assert.Equal(["hello"], lines);
Assert.Equal("!", _buffer.Flush());
}
[Fact]
public void FlushIfIdle_CompletesBufferedLineAfterTimeout()
{
var now = DateTimeOffset.UtcNow;
_buffer.Append("tail", now);
Assert.Null(_buffer.FlushIfIdle(now.AddMilliseconds(500)));
Assert.Equal("tail", _buffer.FlushIfIdle(now.AddSeconds(2)));
}
}

View file

@ -0,0 +1,38 @@
using Elsa.Diagnostics.ConsoleLogs.Services;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Capture;
public class ConsoleLineFormatterTests
{
[Fact]
public void Format_StripsAnsiByDefault()
{
var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()));
var result = formatter.Format("\u001b[31mred\u001b[0m");
Assert.Equal("red", result.Text);
Assert.False(result.Truncated);
}
[Fact]
public void Format_PreservesAnsiWhenConfigured()
{
var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { StripAnsiEscapeSequences = false }));
var result = formatter.Format("\u001b[31mred\u001b[0m");
Assert.Equal("\u001b[31mred\u001b[0m", result.Text);
}
[Fact]
public void Format_TruncatesOversizedLine()
{
var formatter = new ConsoleLineFormatter(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { MaxLineLength = 3 }));
var result = formatter.Format("abcdef");
Assert.Equal("abc", result.Text);
Assert.True(result.Truncated);
}
}

View file

@ -0,0 +1,19 @@
using Elsa.Diagnostics.ConsoleLogs.Extensions;
using Elsa.Diagnostics.ConsoleLogs.Permissions;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests;
public class ConsoleLogsNamingTests
{
[Fact]
public void Permission_UsesDiagnosticsConsoleLogsName()
{
Assert.Equal("read:diagnostics:console-logs", ConsoleLogsPermissions.Read);
}
[Fact]
public void HubRoute_UsesDiagnosticsConsoleLogsPath()
{
Assert.Equal("/elsa/hubs/diagnostics/console-logs", EndpointRouteBuilderExtensions.HubRoute);
}
}

View file

@ -0,0 +1,35 @@
using Elsa.Diagnostics.ConsoleLogs.Contracts;
using Elsa.Diagnostics.ConsoleLogs.Extensions;
using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
using Elsa.Diagnostics.ConsoleLogs.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests;
public class ConsoleLogsRegistrationTests
{
[Fact]
public void AddConsoleLogsServices_WhenNoProviderIsConfigured_UsesInMemoryProvider()
{
var services = new ServiceCollection();
services.AddConsoleLogsServices();
using var serviceProvider = services.BuildServiceProvider();
Assert.IsType<InMemoryConsoleLogProvider>(serviceProvider.GetRequiredService<IConsoleLogProvider>());
Assert.IsType<ConsoleLogSourceRegistry>(serviceProvider.GetRequiredService<IConsoleLogSourceRegistry>());
Assert.IsType<ConsoleLogRedactor>(serviceProvider.GetRequiredService<IConsoleLogRedactor>());
}
[Fact]
public void AddConsoleLogsServices_RegistersHostedCapture()
{
var services = new ServiceCollection();
services.AddConsoleLogsServices();
using var serviceProvider = services.BuildServiceProvider();
Assert.Contains(serviceProvider.GetServices<IHostedService>(), x => x.GetType() == typeof(ConsoleLogCaptureHostedService));
}
}

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.Diagnostics.ConsoleLogs]*</Include>
<Threshold>0</Threshold>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\modules\Elsa.Diagnostics.ConsoleLogs\Elsa.Diagnostics.ConsoleLogs.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,36 @@
using Elsa.Diagnostics.ConsoleLogs.Services;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Filtering;
public class ConsoleLogFilterTests
{
private readonly ConsoleLogLine _line = new()
{
Text = "workflow started",
Stream = ConsoleLogStream.Stdout,
Source = new ConsoleLogSource { Id = "source-a", DisplayName = "Source A", MachineName = "machine" },
ReceivedAt = DateTimeOffset.Parse("2026-05-18T10:00:00Z")
};
[Fact]
public void Matches_FiltersBySource()
{
Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { SourceId = "source-a" }));
Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { SourceId = "source-b" }));
}
[Fact]
public void Matches_FiltersByStream()
{
Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Stream = ConsoleLogStream.Stdout }));
Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Stream = ConsoleLogStream.Stderr }));
}
[Fact]
public void Matches_FiltersByTextAndTime()
{
Assert.True(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Query = "started", From = _line.ReceivedAt.AddSeconds(-1), To = _line.ReceivedAt.AddSeconds(1) }));
Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { Query = "missing" }));
Assert.False(ConsoleLogFilterEvaluator.Matches(_line, new ConsoleLogFilter { From = _line.ReceivedAt.AddSeconds(1) }));
}
}

View file

@ -0,0 +1,53 @@
using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
using Elsa.Diagnostics.ConsoleLogs.Services;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.InMemory;
public class InMemoryConsoleLogProviderDroppedLineTests
{
[Fact]
public async Task GetRecentAsync_IncludesDroppedSummaryWhenRecentBufferOverflows()
{
var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { RecentLogCapacity = 1 }), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())));
await provider.PublishAsync(Line("one"));
await provider.PublishAsync(Line("two"));
var result = await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 });
Assert.Single(result.Dropped!);
Assert.Equal("RecentBufferFull", result.Dropped!.Single().Reason);
}
[Fact]
public async Task GetRecentAsync_ConsumesDroppedSummaries()
{
var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { RecentLogCapacity = 1 }), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())));
await provider.PublishAsync(Line("one"));
await provider.PublishAsync(Line("two"));
Assert.NotEmpty((await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 })).Dropped!);
Assert.Empty((await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 })).Dropped!);
}
[Fact]
public async Task GetRecentAsync_IncludesReportedCaptureDrops()
{
var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())));
provider.ReportDropped(new ConsoleLogDroppedSummary("source", ConsoleLogStream.Stdout, "CaptureChannelFull", 2));
var result = await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 });
var dropped = Assert.Single(result.Dropped!);
Assert.Equal("CaptureChannelFull", dropped.Reason);
Assert.Equal(2, dropped.Count);
}
private static ConsoleLogLine Line(string text) => new()
{
Text = text,
Source = new ConsoleLogSource { Id = "source", DisplayName = "source", MachineName = "machine" }
};
}

View file

@ -0,0 +1,30 @@
using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
using Elsa.Diagnostics.ConsoleLogs.Services;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.InMemory;
public class InMemoryConsoleLogProviderSourceTests
{
[Fact]
public async Task GetRecentAsync_OrdersOverlappingSourcesDeterministically()
{
var provider = new InMemoryConsoleLogProvider(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()), new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())));
var timestamp = DateTimeOffset.UtcNow;
await provider.PublishAsync(Line("b", "b", timestamp, 1));
await provider.PublishAsync(Line("a", "a", timestamp, 1));
var result = await provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 });
Assert.Equal(["a", "b"], result.Items.Select(x => x.Text));
}
private static ConsoleLogLine Line(string text, string sourceId, DateTimeOffset timestamp, long sequence) => new()
{
Text = text,
Source = new ConsoleLogSource { Id = sourceId, DisplayName = sourceId, MachineName = "machine" },
Timestamp = timestamp,
ReceivedAt = timestamp,
Sequence = sequence
};
}

View file

@ -0,0 +1,60 @@
using Elsa.Diagnostics.ConsoleLogs.Providers.InMemory;
using Elsa.Diagnostics.ConsoleLogs.Services;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.InMemory;
public class InMemoryConsoleLogProviderTests
{
private readonly ConsoleLogSource _source = new() { Id = "local", DisplayName = "local", MachineName = "machine", Health = ConsoleLogSourceHealth.Connected };
private readonly InMemoryConsoleLogProvider _provider;
public InMemoryConsoleLogProviderTests()
{
_provider = new(
Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { RecentLogCapacity = 2, MaxRecentQuerySize = 1, SubscriberChannelCapacity = 10 }),
new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions())));
}
[Fact]
public async Task GetRecentAsync_ClampsLimitAndOrdersByReceivedAt()
{
await _provider.PublishAsync(Line("one", 1));
await _provider.PublishAsync(Line("two", 2));
var result = await _provider.GetRecentAsync(new ConsoleLogFilter { Limit = 10 });
var line = Assert.Single(result.Items);
Assert.Equal("two", line.Text);
}
[Fact]
public async Task SubscribeAsync_StreamsMatchingLines()
{
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var readTask = ReadOneAsync(_provider.SubscribeAsync(new ConsoleLogFilter { Stream = ConsoleLogStream.Stderr }, cancellation.Token), cancellation.Token);
await _provider.PublishAsync(Line("ignored", 1));
await _provider.PublishAsync(Line("error", 2, ConsoleLogStream.Stderr));
var item = await readTask;
Assert.Equal("error", item.Line?.Text);
}
private ConsoleLogLine Line(string text, long sequence, ConsoleLogStream stream = ConsoleLogStream.Stdout) => new()
{
Text = text,
Sequence = sequence,
Stream = stream,
Source = _source,
Timestamp = DateTimeOffset.UnixEpoch.AddSeconds(sequence),
ReceivedAt = DateTimeOffset.UnixEpoch.AddSeconds(sequence)
};
private static async Task<ConsoleLogStreamItem> ReadOneAsync(IAsyncEnumerable<ConsoleLogStreamItem> items, CancellationToken cancellationToken)
{
await foreach (var item in items.WithCancellation(cancellationToken))
return item;
throw new InvalidOperationException("No item was streamed.");
}
}

View file

@ -0,0 +1,52 @@
using Elsa.Diagnostics.ConsoleLogs.Contracts;
using Elsa.Diagnostics.ConsoleLogs.Services;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Redaction;
public class ConsoleLogProviderRedactionTests
{
[Fact]
public async Task CaptureTee_PublishesRedactedLinesToProvider()
{
var originalOut = Console.Out;
var provider = new CapturingProvider();
var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()));
var options = Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions());
var capture = new ConsoleCaptureTee(provider, registry, new ConsoleLogRedactor(options), new ConsoleLineFormatter(options), options);
try
{
Console.SetOut(TextWriter.Null);
await capture.StartAsync();
Console.WriteLine(string.Concat("pass", "word", "=", "sample-value"));
await capture.StopAsync();
Assert.Equal("[Redacted]", Assert.Single(provider.Lines).Text);
}
finally
{
await capture.StopAsync();
Console.SetOut(originalOut);
}
}
private sealed class CapturingProvider : IConsoleLogProvider
{
public List<ConsoleLogLine> Lines { get; } = [];
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default)
{
Lines.Add(line);
return ValueTask.CompletedTask;
}
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new RecentConsoleLogsResult(Lines));
public async IAsyncEnumerable<ConsoleLogStreamItem> SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield break;
}
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult<IReadOnlyCollection<ConsoleLogSource>>([]);
}
}

View file

@ -0,0 +1,47 @@
using Elsa.Diagnostics.ConsoleLogs.Services;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Redaction;
public class ConsoleLogRedactorTests
{
private readonly ConsoleLogRedactor _redactor = new(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()));
[Fact]
public void Redact_MasksSensitiveLineText()
{
var line = CreateLine(string.Concat("Authorization: ", "Bearer ", "sample-token"));
var redacted = _redactor.Redact(line);
Assert.Equal("Authorization: [Redacted]", redacted.Text);
}
[Fact]
public void Redact_MasksSensitiveSourceMetadata()
{
var line = CreateLine("hello") with
{
Source = CreateSource() with
{
Metadata = new Dictionary<string, string?> { ["apiKey"] = "secret-value" }
}
};
var redacted = _redactor.Redact(line);
Assert.Equal("[Redacted]", redacted.Source.Metadata["apiKey"]);
}
private static ConsoleLogLine CreateLine(string text) => new()
{
Text = text,
Source = CreateSource()
};
private static ConsoleLogSource CreateSource() => new()
{
Id = "source",
DisplayName = "source",
MachineName = "machine"
};
}

View file

@ -0,0 +1,44 @@
using Elsa.Diagnostics.ConsoleLogs.Services;
namespace Elsa.Diagnostics.ConsoleLogs.UnitTests.Sources;
public class ConsoleLogSourceRegistryTests
{
[Fact]
public void MarkSeen_AddsUnknownSourceAndRaisesChange()
{
var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions()));
ConsoleLogSource? changed = null;
registry.SourceChanged += source => changed = source;
registry.MarkSeen("remote", DateTimeOffset.UtcNow);
Assert.NotNull(changed);
Assert.Contains(registry.List(), x => x.Id == "remote" && x.MachineName == "" && x.Health == ConsoleLogSourceHealth.Connected);
}
[Fact]
public void List_MarksOldSourcesStale()
{
var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { SourceHeartbeatTimeout = TimeSpan.FromSeconds(1) }));
registry.MarkSeen("remote", DateTimeOffset.UtcNow.AddMinutes(-1));
Assert.Contains(registry.List(), x => x.Id == "remote" && x.Health == ConsoleLogSourceHealth.Stale);
}
[Fact]
public void MarkSeen_AfterSourceBecameStale_RaisesConnectedChange()
{
var registry = new ConsoleLogSourceRegistry(Microsoft.Extensions.Options.Options.Create(new ConsoleLogsOptions { SourceHeartbeatTimeout = TimeSpan.FromSeconds(1) }));
var changes = new List<ConsoleLogSource>();
registry.SourceChanged += changes.Add;
registry.MarkSeen("remote", DateTimeOffset.UtcNow.AddMinutes(-1));
registry.List();
registry.MarkSeen("remote", DateTimeOffset.UtcNow);
Assert.Contains(changes, x => x.Id == "remote" && x.Health == ConsoleLogSourceHealth.Stale);
Assert.Contains(changes, x => x.Id == "remote" && x.Health == ConsoleLogSourceHealth.Connected);
}
}

View file

@ -0,0 +1,5 @@
global using System.Runtime.CompilerServices;
global using Elsa.Diagnostics.ConsoleLogs.Models;
global using Elsa.Diagnostics.ConsoleLogs.Options;
global using Microsoft.Extensions.Options;
global using Xunit;