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<ReportDatabaseHealthCheck>("report-database");
// File storage health is GrydFiles': AddGrydFilesHealthCheck() reports the scanner and the bucket.

Multi-Tenancy ​

GrydReports inherits multi-tenancy from the Gryd.IO core framework.

Tenant Isolation ​

All report data (executions, schedules) is automatically scoped by TenantId. Queries automatically include tenant filtering via the base entity configuration. Generated files are stored in GrydFiles under the execution's tenant — also for scheduled runs, which take the tenant from the schedule — so a download URL is only ever issued inside that tenant.

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
  • Useful for platform-level reporting
  • Respect individual tenant scheduling and delivery

Where files are stored ​

Report files are GrydFiles objects in the gryd.report profile. Their keys are built by GrydFiles (tenant, profile, date, file id) and never leave the module — clients get a short-lived URL from GET /reports/{id}/download instead.


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 for isolated tests, and substitute IServerFileStore (GrydFiles) for the file storage — the reports module only stores and releases files through it:

csharp
services.AddScoped(_ => Substitute.For<IServerFileStore>());
services.AddGrydReports(options =>
{
    options.ConnectionString = "InMemory";
});

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.