Table of Contents

Report generation interceptors

Report generation interceptors allow you to hook into the report generation process to inspect or modify generated reports before they are delivered to users or processed by callbacks. This is particularly useful for adding metadata, applying transformations, implementing audit trails, or cancelling standard processing.

Overview

When a report is generated (either from the client or from server-side code), you can register interceptors that will be invoked after the PDF is generated but before the notification is sent to the user or the callback is executed.

Important

Interceptors are only invoked for PDF generation (when generating a file to download or process). They are not triggered for report previews displayed in the viewer, as previews do not generate a PDF file.

Interceptors receive a ReportGeneratedInterceptorContext containing:

  • The raw PDF content
  • The report template name
  • The filename to use
  • The generation identifier
  • The request origin (client or server)

You can:

  • Read the report content
  • Modify the PDF content by calling UpdateContent(byte[])
  • Cancel standard processing by setting Cancel = true
Warning

If you want to modify the PDF content in a deployed environnement, you'll need to set reportingTempDirectoryAccessMode to ReadWrite in your cluster backend Helm configuration.

Creating an interceptor

To create an interceptor, implement the IReportGeneratedInterceptor interface:

using System.Threading;
using System.Threading.Tasks;
using GroupeIsa.Neos.Application.Reports;

public class MyReportInterceptor : IReportGeneratedInterceptor
{
    public Task OnReportGeneratedAsync(
        ReportGeneratedInterceptorContext context,
        CancellationToken cancellationToken)
    {
        // Your logic here

        return Task.CompletedTask;
    }
}

Example: adding metadata to PDF

This example demonstrates how to add generation metadata (template name, cluster, version, and generation identifier) at the bottom of each page in PDF reports requested by clients:

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using GroupeIsa.Neos.Application;
using GroupeIsa.Neos.Application.Reports;
using iText.IO.Font.Constants;
using iText.Kernel.Colors;
using iText.Kernel.Font;
using iText.Kernel.Geom;
using iText.Kernel.Pdf;
using iText.Kernel.Pdf.Canvas;

public class ReportGenerationMetadataInterceptor : IReportGeneratedInterceptor
{
    private const float FontSize = 8f;
    private const float MarginLeft = 10f;
    private const float MarginBottom = 10f;

    private readonly IApplicationInfo _applicationInfo;

    public ReportGenerationMetadataInterceptor(IApplicationInfo applicationInfo)
    {
        _applicationInfo = applicationInfo;
    }

    public Task OnReportGeneratedAsync(
        ReportGeneratedInterceptorContext context,
        CancellationToken cancellationToken)
    {
        // Only process reports requested by the client
        if (context.RequestOrigin != ReportRequestOrigin.Client)
        {
            return Task.CompletedTask;
        }

        byte[] modifiedContent = AddGenerationMetadataFooter(
            context.ReportContent,
            context.TemplateName,
            context.GenerationIdentifier);

        context.UpdateContent(modifiedContent);

        return Task.CompletedTask;
    }

    private byte[] AddGenerationMetadataFooter(
        byte[] pdfContent,
        string templateName,
        Guid generationIdentifier)
    {
        string footerText = $"Template: {templateName} | Generated by {_applicationInfo.ClusterName} v{_applicationInfo.ClusterVersion} | ID: {generationIdentifier}";

        using MemoryStream inputStream = new(pdfContent);
        using MemoryStream outputStream = new();
        using PdfDocument pdfDocument = new(
            new PdfReader(inputStream),
            new PdfWriter(outputStream));

        PdfFont font = PdfFontFactory.CreateFont(StandardFonts.HELVETICA);

        int numberOfPages = pdfDocument.GetNumberOfPages();
        for (int i = 1; i <= numberOfPages; i++)
        {
            PdfPage page = pdfDocument.GetPage(i);
            Rectangle pageSize = page.GetPageSize();

            PdfCanvas canvas = new(page);
            canvas.BeginText()
                .SetFontAndSize(font, FontSize)
                .SetColor(ColorConstants.GRAY, true)
                .MoveText(
                    pageSize.GetLeft() + MarginLeft,
                    pageSize.GetBottom() + MarginBottom)
                .ShowText(footerText)
                .EndText();
        }

        pdfDocument.Close();

        return outputStream.ToArray();
    }
}
Note

This example uses the iText7 library to manipulate PDF content. You'll need to add the appropriate NuGet package references to your business assembly project.

Note

This example uses a HELVETICA font. For this to work in a deployed environment, remember to add it to the backend/taskrunner image of your cluster. See this article for basic cluster DockerFile examples. Example in a cluster docker file :

# --- FROM "harbor.hexanet.fr:8443/neos/neos-fmk:..." AS builder...
# --- FROM mcr.microsoft.com/dotnet/aspnet:...
# Copy your font file in the final image layer :
COPY ./my-helvetica-font.ttf /usr/share/fonts/my-helvetica-font.ttf
# --- USER ...
# --- ENTRYPOINT ...

In this example, image tags are intentionally generic. Keep your actual base image tags aligned with your targeted Neos/.NET minor version and your security update process.

Example: audit logging

using System.Threading;
using System.Threading.Tasks;
using GroupeIsa.Neos.Application.Reports;

public class AuditReportInterceptor : IReportGeneratedInterceptor
{
    private readonly IAuditService _auditService;

    public AuditReportInterceptor(IAuditService auditService)
    {
        _auditService = auditService;
    }

    public async Task OnReportGeneratedAsync(
        ReportGeneratedInterceptorContext context,
        CancellationToken cancellationToken)
    {
        await _auditService.LogReportGenerationAsync(
            context.TemplateName,
            context.GenerationIdentifier,
            context.RequestOrigin,
            context.ReportContent.Count, // PDF size in bytes
            cancellationToken);
    }
}

Example: conditional processing cancellation

using System.Threading;
using System.Threading.Tasks;
using GroupeIsa.Neos.Application.Reports;

public class ConditionalCancellationInterceptor : IReportGeneratedInterceptor
{
    private readonly IReportApprovalService _approvalService;

    public ConditionalCancellationInterceptor(IReportApprovalService approvalService)
    {
        _approvalService = approvalService;
    }

    public async Task OnReportGeneratedAsync(
        ReportGeneratedInterceptorContext context,
        CancellationToken cancellationToken)
    {
        // Check if the report requires approval
        bool requiresApproval = await _approvalService.RequiresApprovalAsync(
            context.TemplateName,
            cancellationToken);

        if (requiresApproval)
        {
            // Store the report for approval
            await _approvalService.StoreForApprovalAsync(
                context.GenerationIdentifier,
                context.ReportContent.ToArray(),
                cancellationToken);

            // Cancel standard processing (no notification/callback)
            context.Cancel = true;
        }
    }
}

Registering interceptors

Once you've created your interceptor, you need to register it in your business assembly's Startup.cs file using the extension methods provided by ReportInterceptorServiceCollectionExtensions.

Global registration

To register an interceptor that will be invoked for all report templates:

using GroupeIsa.Neos.Application.Reports;
using Microsoft.Extensions.DependencyInjection;

public static class Startup
{
    public static void ConfigureServices(IServiceCollection services)
    {
        // This interceptor will be invoked for all report generations
        services.AddReportGeneratedInterceptor<AuditReportInterceptor>();
    }
}

Template-specific registration

To register an interceptor for specific report templates only:

using GroupeIsa.Neos.Application.Reports;
using Microsoft.Extensions.DependencyInjection;

public static class Startup
{
    public static void ConfigureServices(IServiceCollection services)
    {
        // This interceptor will only be invoked for the "Invoice" template
        services.AddReportGeneratedInterceptor<InvoiceInterceptor>("Invoice");

        // This interceptor will be invoked for multiple templates
        services.AddReportGeneratedInterceptor<FinancialReportInterceptor>(
            "Invoice",
            "OrderConfirmation",
            "PaymentReceipt");
    }
}

Request origin

The ReportRequestOrigin enum indicates how the report generation was initiated:

Value Description Standard processing
Client The report was requested by a user from the frontend (PDF generation, not preview) A notification will be sent to the user with a download link
Server The report was requested by server-side code using IReportGenerator The callback method will be executed
Note

Report previews shown in the viewer do not trigger interceptors because they do not generate a PDF file. Only actual PDF generation requests (download or server-side generation) invoke interceptors.

You can use this information to apply different logic depending on the context:

public Task OnReportGeneratedAsync(
    ReportGeneratedInterceptorContext context,
    CancellationToken cancellationToken)
{
    if (context.RequestOrigin == ReportRequestOrigin.Client)
    {
        // Logic for user-requested reports
    }
    else
    {
        // Logic for server-requested reports
    }

    return Task.CompletedTask;
}

Execution order

When multiple interceptors are registered (global and/or template-specific), they are executed in the following order:

  1. Global interceptors are executed first, in the order they were registered
  2. Template-specific interceptors are executed next, in the order they were registered
Important

If any interceptor sets Cancel = true, the standard processing is cancelled, but all registered interceptors will still be executed. The cancellation only affects the final notification/callback, not the interceptor chain.

Best practices

Dependency injection

Interceptors are instantiated by the dependency injection container, so you can inject services into their constructors:

public class MyInterceptor : IReportGeneratedInterceptor
{
    private readonly ILogger _logger;
    private readonly IMyService _myService;

    public MyInterceptor(ILogger logger, IMyService myService)
    {
        _logger = logger;
        _myService = myService;
    }

    // ...
}

Performance considerations

  • Keep interceptor logic fast to avoid delaying report delivery
  • For heavy processing, consider using background tasks
  • Be careful when modifying large PDF files

Error handling

If an interceptor throws an exception:

  • The report generation is considered failed
  • Subsequent interceptors are not executed
  • The error is logged and returned to the caller

Therefore, ensure proper error handling within your interceptors if you want to allow processing to continue despite errors:

public async Task OnReportGeneratedAsync(
    ReportGeneratedInterceptorContext context,
    CancellationToken cancellationToken)
{
    try
    {
        // Your logic
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Error in report interceptor");
        // Don't rethrow if you want other interceptors to continue
    }
}

Content modification

When modifying PDF content:

  • Always validate the new content before calling UpdateContent()
  • Ensure the modified content is still a valid PDF
  • Test thoroughly with different report templates

Technical demonstration

A complete working example is available in the TechnicalDemos cluster:

  • Interceptor: demos/technicaldemos/modules/Reports/businessAssembly/Application/Interceptors/ReportGenerationMetadataInterceptor.cs
  • Registration: demos/technicaldemos/modules/Reports/businessAssembly/Application/Startup.cs

This example demonstrates how to add generation metadata to the bottom of each page in client-requested PDF reports.

See also

Alternative approach: modifying PDF in callback

While interceptors provide a centralized way to modify generated reports, there is an alternative technique: modifying the PDF directly within your callback method and creating a forged response.

How it works

When using IReportGenerator, your callback method receives a ReportGenerationResponse. You can:

  1. Extract the PDF content from response.ReportContent
  2. Modify the PDF using libraries like iText7
  3. Create a new response using ReportGenerationResponse.ForgeSuccessfulGenerationResponse()
  4. Use the forged response for subsequent operations (notifications, storage)

Example: appending terms and conditions

This example shows how to append a company's terms and conditions PDF to a generated report:

using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GroupeIsa.Neos.Shared.Reports;
using iText.Kernel.Pdf;
using iText.Kernel.Utils;

public class EmailSender
{
    private readonly ICompanyRepository _companyRepository;
    private readonly ITemporaryFileStorage _temporaryFileStorage;
    private readonly IUserNotification _userNotification;

    public EmailSender(
        ICompanyRepository companyRepository,
        ITemporaryFileStorage temporaryFileStorage,
        IUserNotification userNotification)
    {
        _companyRepository = companyRepository;
        _temporaryFileStorage = temporaryFileStorage;
        _userNotification = userNotification;
    }

    // This method is called by IReportGenerator as the callback
    public async Task SendEmailWithReportAttachmentAsync(
        ReportGenerationResponse response,
        string companyName)
    {
        if (response.GenerationSucceed)
        {
            // Get company terms and conditions
            Company? company = await _companyRepository.GetQuery()
                .Where(c => c.Name == companyName)
                .SingleAsync(CancellationToken.None);

            BinaryFile? termsAndConditionsFile = company?.TermsAndConditionsFile;

            if (termsAndConditionsFile != null && response.ReportContent != null)
            {
                // Merge the generated report with the terms and conditions
                byte[] mergedContent = MergePdfDocuments(
                    response.ReportContent,
                    termsAndConditionsFile.Content);

                // Create a forged response with the merged PDF
                ReportGenerationResponse forgedResponse =
                    ReportGenerationResponse.ForgeSuccessfulGenerationResponse(
                        response.FilenameToUse,
                        documentContent: mergedContent);

                // Replace the original response with the forged one
                response = forgedResponse;
            }

            // Send notification with the (potentially modified) report
            ReportGenerationSucceededNotificationArgs notifArgs =
                await ReportGenerationSucceededNotificationArgs
                    .CreateFromResponseAsync(
                        _temporaryFileStorage,
                        response,
                        logger: null,
                        CancellationToken.None);

            await _userNotification.SendReportGenerationSucceededNotificationAsync(
                notifArgs,
                CancellationToken.None);
        }
    }

    private static byte[] MergePdfDocuments(byte[] pdfContent1, byte[] pdfContent2)
    {
        MemoryStream outputStream = new();

        using (PdfDocument outputDocument = new(new PdfWriter(outputStream)))
        {
            outputDocument.GetWriter().SetCloseStream(false);

            PdfMerger merger = new(outputDocument);

            using (MemoryStream stream1 = new(pdfContent1))
            using (PdfDocument document1 = new(new PdfReader(stream1)))
            {
                merger.Merge(document1, 1, document1.GetNumberOfPages());
            }

            using (MemoryStream stream2 = new(pdfContent2))
            using (PdfDocument document2 = new(new PdfReader(stream2)))
            {
                merger.Merge(document2, 1, document2.GetNumberOfPages());
            }

            merger.Close();
        }

        outputStream.Seek(0, SeekOrigin.Begin);
        return outputStream.ToArray();
    }
}

ForgeSuccessfulGenerationResponse method

The ReportGenerationResponse.ForgeSuccessfulGenerationResponse() factory method creates a new response object that resembles what the reporting service would have produced. It uses the framework's legacy mode capabilities to automatically store the document in the $NeosFile database table.

// Create a forged response from raw bytes
ReportGenerationResponse forgedResponse =
    ReportGenerationResponse.ForgeSuccessfulGenerationResponse(
        filenameToUse: "modified-report.pdf",
        documentContent: modifiedPdfBytes);

// Or from Base64 (if already encoded)
ReportGenerationResponse forgedResponse =
    ReportGenerationResponse.ForgeSuccessfulGenerationResponseFromBase64Content(
        filenameToUse: "modified-report.pdf",
        documentContentInBase64: base64EncodedPdf);
Important

The forged response uses the legacy storage mode, which stores the PDF in the database. This ensures compatibility with the notification and download APIs.

Comparison: interceptors vs callback modification

Aspect Interceptors Callback modification
When to use Automatic, cross-cutting modifications Contextual, business-specific modifications
Configuration Centralized in Startup.cs Implemented in each callback
Scope All reports (global) or specific templates Only for reports using IReportGenerator
Code location Separate interceptor classes Within callback methods
Reusability High (register once, applies everywhere) Low (per-callback implementation)
Flexibility Limited to report context Full access to callback context and dependencies
Testing Test interceptor independently Test with full callback logic
Client requests ✅ Works (for PDF generation) ❌ Not applicable (no callback)
Server requests ✅ Works ✅ Works
Complexity Low (framework handles invocation) Medium (manual response manipulation)
Performance Overhead for all matching reports Overhead only when modification is needed

When to use each approach

Use interceptors when:

  • You need to apply the same modification to multiple reports
  • The modification logic is independent of the callback context
  • You want centralized, declarative configuration
  • You need to modify reports requested from both client and server

Use callback modification when:

  • The modification depends on business logic or callback parameters
  • You need different modifications for different scenarios
  • You only generate reports from server-side code (IReportGenerator)
  • You want explicit control over when and how the PDF is modified

Use both when:

  • You have common modifications (interceptors) + specific ones (callback)
  • For example: watermark for all reports (interceptor) + append terms for invoices (callback)
Tip

Both techniques can be combined. Interceptors run first, then your callback receives the already-modified PDF and can apply additional transformations.

Technical demonstration

A complete working example of callback-based PDF modification is available in the TechnicalDemos cluster:

  • Callback with modification: demos/technicaldemos/modules/Reports/businessAssembly/Application/EmailSender.cs (see ActualNotificationSendingAsync method)
  • UI view: demos/technicaldemos/modules/Reports/metadata/UIViews/ReportsExecutePdfFromAssemblyUI.yml

This example demonstrates how to append a company's terms and conditions to a generated report.