Skip to content

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 CacheExpiration elapses
  • When the template version changes (increment Version property)
  • 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

ScenarioUse StandardUse 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:

MetricTypeDescription
gryd.reports.generatedCounterReports generated (success + fail)
gryd.reports.generation_durationHistogramGeneration time in milliseconds
gryd.reports.active_generationsGaugeConcurrent report generations
gryd.reports.queue_lengthGaugeAsync generation queue depth
gryd.reports.cache_hitsCounterCache hit count
gryd.reports.delivery_countCounterDelivery 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.pdf

Security

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
    }));

Released under the MIT License.