Appearance
Advanced
Advanced features including filters, caching, streaming, observability, multi-tenancy, security, and testing.
Report Filters
Filters allow intercepting the report pipeline at key stages. Implement IReportFilter for cross-cutting concerns like logging, validation, or data enrichment.
Pipeline Stages
OnBeforeDataFetch → DataSource → OnAfterDataFetch → OnBeforeRender → Renderer → OnAfterGeneration
│
OnError (on failure)Implementing a Filter
csharp
public class AuditReportFilter : IReportFilter
{
public int Order => 10; // lower = runs first
public Task OnBeforeDataFetchAsync(ReportFilterContext context, CancellationToken ct)
{
context.Items["startTime"] = DateTime.UtcNow;
return Task.CompletedTask;
}
public Task OnAfterDataFetchAsync(ReportFilterContext context, CancellationToken ct)
{
var rowCount = (context.Data as ICollection)?.Count ?? 0;
context.Items["rowCount"] = rowCount;
return Task.CompletedTask;
}
public Task OnBeforeRenderAsync(ReportFilterContext context, CancellationToken ct)
{
// Modify data or parameters before rendering
return Task.CompletedTask;
}
public Task OnAfterGenerationAsync(ReportFilterContext context, CancellationToken ct)
{
var elapsed = DateTime.UtcNow - (DateTime)context.Items["startTime"];
Log.Information("Report {Template} generated in {Elapsed}ms",
context.TemplateId, elapsed.TotalMilliseconds);
return Task.CompletedTask;
}
public Task OnErrorAsync(ReportFilterContext context, Exception exception, CancellationToken ct)
{
Log.Error(exception, "Report {Template} failed", context.TemplateId);
return Task.CompletedTask;
}
}Registering Filters
csharp
services.AddGrydReports(options => { /* ... */ })
.AddReportFilter<AuditReportFilter>()
.AddReportFilter<ValidationReportFilter>();Filters execute in Order ascending. Multiple filters form a chain-of-responsibility pattern.
Caching
Enable output caching to avoid regenerating identical reports.
Configuration
csharp
services.AddGrydReports(options =>
{
options.Caching.Enabled = true;
options.Caching.DefaultExpiration = TimeSpan.FromHours(1);
options.Caching.MaxCacheSize = 500; // MB
});Per-Template Expiration
Override cache duration per template:
csharp
public class DailySalesTemplate : IReportTemplate
{
// ...
public TimeSpan? CacheExpiration => TimeSpan.FromMinutes(30);
}Cache Key
Cache keys are computed from TemplateId + Format + Parameters (serialized). Identical requests within the expiration window return the cached report instantly.
Invalidation
Cache entries are invalidated:
- Automatically when
CacheExpirationelapses - When the template version changes (increment
Versionproperty) - When manually triggered via the API
Streaming Data Sources
For reports with large datasets, use IStreamingReportDataSource to avoid loading all data into memory.
csharp
public class LargeExportDataSource : IStreamingReportDataSource
{
private readonly AppDbContext _db;
public LargeExportDataSource(AppDbContext db) => _db = db;
public async IAsyncEnumerable<object> GetDataStreamAsync(
IDictionary<string, object?> parameters,
[EnumeratorCancellation] CancellationToken ct = default)
{
var query = _db.Transactions
.AsNoTracking()
.OrderBy(t => t.Date);
await foreach (var batch in query.AsAsyncEnumerable().WithCancellation(ct))
{
yield return batch;
}
}
}When to Use Streaming
| Scenario | Use Standard | Use Streaming |
|---|---|---|
| < 10,000 rows | ✅ | — |
| 10k–100k rows | — | ✅ Recommended |
| > 100k rows | ❌ | ✅ Required |
| Real-time data | — | ✅ |
Streaming data sources are compatible with CSV and Excel renderers. PDF and HTML renderers buffer the stream internally.
Observability
GrydReports integrates with Gryd.Observability and OpenTelemetry for full pipeline visibility.
Metrics
ReportMetricsCollector emits:
| Metric | Type | Description |
|---|---|---|
gryd.reports.generated | Counter | Reports generated (success + fail) |
gryd.reports.generation_duration | Histogram | Generation time in milliseconds |
gryd.reports.active_generations | Gauge | Concurrent report generations |
gryd.reports.queue_length | Gauge | Async generation queue depth |
gryd.reports.cache_hits | Counter | Cache hit count |
gryd.reports.delivery_count | Counter | Delivery attempts by method |
Distributed Tracing
ReportActivitySourceProvider creates spans for each pipeline stage:
gryd.reports.generate
├─ gryd.reports.data_fetch
├─ gryd.reports.render
├─ gryd.reports.store
└─ gryd.reports.deliver (optional)Configuration
csharp
// OpenTelemetry integration
builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing.AddSource("Gryd.Reports");
})
.WithMetrics(metrics =>
{
metrics.AddMeter("Gryd.Reports");
});Health Checks
csharp
builder.Services.AddHealthChecks()
.AddCheck<ReportStorageHealthCheck>("report-storage")
.AddCheck<ReportDatabaseHealthCheck>("report-database");Multi-Tenancy
GrydReports inherits multi-tenancy from the Gryd.IO core framework.
Tenant Isolation
All report data (executions, schedules, files) is automatically scoped by TenantId. Queries automatically include tenant filtering via the base entity configuration.
Global Reports
Mark templates as global to make them available to all tenants:
csharp
services.AddGrydReports(options =>
{
options.GlobalReports = new[] { "system-audit", "platform-metrics" };
});Global reports:
- Available to all tenants without specific configuration
- Store outputs in a shared storage path
- Useful for platform-level reporting
- Respect individual tenant scheduling and delivery
Storage Layout
reports/
├── tenants/
│ ├── {tenant-a}/
│ │ ├── monthly-sales-2026-01.pdf
│ │ └── inventory-q1.xlsx
│ └── {tenant-b}/
│ └── monthly-sales-2026-01.pdf
└── global/
└── system-audit-2026-01.pdfSecurity
Per-Template Permissions
Restrict template access via RBAC:
csharp
services.AddGrydReports(options =>
{
options.Permissions.RequireAuthentication = true;
options.Permissions.TemplatePermissions = new Dictionary<string, string[]>
{
["financial-report"] = new[] { "Finance.Manager", "Finance.Director" },
["hr-report"] = new[] { "HR.Admin" }
};
});When a user requests a report, their roles are checked against the template permissions. If no permission is configured for a template, it defaults to requiring authentication only.
Digital Signatures
Sign generated PDFs for compliance:
csharp
services.AddGrydReports(options =>
{
options.DigitalSignature.Enabled = true;
options.DigitalSignature.CertificatePath = "/certs/report-signing.pfx";
options.DigitalSignature.CertificatePassword = "secure-password";
options.DigitalSignature.Reason = "Official company report";
options.DigitalSignature.Location = "São Paulo, BR";
});Digital signatures are applied after PDF generation and appear in the document's signature panel.
Testing
Unit Testing Templates
csharp
public class SalesReportTemplateTests
{
[Fact]
public void Template_ShouldHaveCorrectMetadata()
{
var template = new SalesReportTemplate();
template.TemplateId.Should().Be("sales-report");
template.SupportedFormats.Should().Contain(ReportFormat.Pdf);
template.Parameters.Should().ContainSingle(p => p.Name == "month");
}
}Unit Testing Data Sources
csharp
public class SalesDataSourceTests
{
[Fact]
public async Task GetDataAsync_ShouldReturnFilteredData()
{
// Arrange
var dbContext = TestDbContextFactory.Create();
dbContext.Sales.AddRange(/* seed data */);
await dbContext.SaveChangesAsync();
var dataSource = new SalesDataSource(dbContext);
var parameters = new Dictionary<string, object?>
{
["month"] = 1,
["year"] = 2026
};
// Act
var data = await dataSource.GetDataAsync(parameters);
// Assert
data.Should().NotBeNull();
((IEnumerable<SalesRow>)data).Should().HaveCount(15);
}
}Unit Testing Filters
csharp
public class AuditFilterTests
{
[Fact]
public async Task OnAfterGeneration_ShouldLogDuration()
{
var logger = Substitute.For<ILogger>();
var filter = new AuditReportFilter(logger);
var context = new ReportFilterContext("sales-report", ReportFormat.Pdf);
context.Items["startTime"] = DateTime.UtcNow.AddSeconds(-2);
await filter.OnAfterGenerationAsync(context, CancellationToken.None);
logger.ReceivedWithAnyArgs().Information(default!, default, default);
}
}Integration Testing with TestHost
csharp
public class ReportApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ReportApiIntegrationTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GenerateReport_ShouldReturnCreated()
{
var request = new
{
TemplateId = "test-report",
Format = 1,
Parameters = new { month = 1 }
};
var response = await _client.PostAsJsonAsync("/api/v1/reports/generate", request);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
result.GetProperty("status").GetInt32().Should().Be(3); // Completed
}
[Fact]
public async Task DownloadReport_ShouldReturnFile()
{
// Generate first
var genResponse = await _client.PostAsJsonAsync("/api/v1/reports/generate", new
{
TemplateId = "test-report",
Format = 1
});
var report = await genResponse.Content.ReadFromJsonAsync<JsonElement>();
var reportId = report.GetProperty("id").GetString();
// Download
var response = await _client.GetAsync($"/api/v1/reports/{reportId}/download");
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("application/pdf");
}
}Test Helpers
Use InMemoryReportStore and InMemoryReportFileStore for isolated tests:
csharp
services.AddGrydReports(options =>
{
options.ConnectionString = "InMemory";
options.LocalStoragePath = Path.GetTempPath();
});NSubstitute Mocks
csharp
var generator = Substitute.For<IReportGenerator>();
generator.GenerateAsync(Arg.Any<GenerateReportCommand>(), Arg.Any<CancellationToken>())
.Returns(Result<ReportOutput>.Success(new ReportOutput
{
ReportId = Guid.NewGuid(),
FileName = "test.pdf",
ContentType = "application/pdf",
FileSizeBytes = 1024
}));