SaveTrainParameters

Serializes train input and output parameters to JSON and stores them in the Metadata.Input and Metadata.Output fields. Enables parameter inspection in the dashboard and database.

The input is written with the row a run writes when it starts, so a run that never finishes (a Lambda timeout, a killed container, a deploy mid-run) still has its input on its InProgress row. The output is written with the outcome.

Signature

public static TBuilder SaveTrainParameters<TBuilder>(
    this TBuilder effectBuilder,
    JsonSerializerOptions? jsonSerializerOptions = null,
    Action<ParameterEffectConfiguration>? configure = null
)
    where TBuilder : TraxEffectBuilder

The generic type parameter TBuilder is inferred by the compiler, so callers just write .SaveTrainParameters(). This preserves the concrete builder type through chaining (e.g., TraxEffectBuilderWithData stays as TraxEffectBuilderWithData).

Parameters

ParameterTypeRequiredDefaultDescription
jsonSerializerOptionsJsonSerializerOptions?NoTraxJsonSerializationOptions.DefaultCustom System.Text.Json options for parameter serialization
configureAction<ParameterEffectConfiguration>?NonullOptional callback to configure which parameters are serialized

ParameterEffectConfiguration

PropertyTypeDefaultDescription
SaveInputsbooltrueWhether to serialize train input parameters to Metadata.Input
SaveOutputsbooltrueWhether to serialize train output parameters to Metadata.Output
MaxParameterBytesint?1048576 (1 MiB)Hard byte ceiling per serialized parameter (input and output). A payload that serializes past this many UTF-8 bytes is aborted mid-serialization and stored as {"_truncated": true, "_maxBytes": N} instead. null removes the ceiling. Must be positive when set.
ShouldSaveInputsFunc<string, bool>?nullPredicate receiving the canonical train name (Metadata.Name); return false to skip serializing that train's input. Also the way to express an opt-in, which a list of exclusions cannot.
ShouldSaveOutputsFunc<string, bool>?nullPredicate receiving the canonical train name (Metadata.Name); return false to skip serializing that train's output. The escape hatch for cases the ExcludeOutput helpers can't express.

The configuration is registered as a singleton and can also be modified at runtime via the dashboard's Effects page.

Per-train opt-out helpers

For the common case (a known set of trains), use the ExcludeInput and ExcludeOutput helpers instead of a predicate. Each skips serialization for trains whose canonical name contains the given fragment, and returns the configuration for chaining.

MethodDescription
ExcludeInput(string fragment)Skip input for trains whose Metadata.Name contains fragment.
ExcludeInput(Type type)Skip input for trains whose name contains type.FullName.
ExcludeInput<TTrain>()Same as the Type overload, using typeof(TTrain).
ExcludeOutput(string fragment)Skip output for trains whose Metadata.Name contains fragment.
ExcludeOutput(Type type)Skip output for trains whose name contains type.FullName.
ExcludeOutput<TTrain>()Same as the Type overload, using typeof(TTrain).

The two sides are independent: excluding a train's input leaves its output alone, and the reverse.

Matching is a substring check against the canonical name, so pass the type that appears in that name: the train interface for named routes, or the request/query type for trains dispatched by input type (e.g. via the MediatR bridge, where Metadata.Name is the assembly-qualified request type). MaxParameterBytes is the automatic safety net for the trains you did not predict; the exclusion lists are the explicit knob for the ones you did.

Saving only some trains' inputs

Exclusions answer "everything except these". When the list worth keeping is the short one, use the predicate instead:

cfg.ShouldSaveInputs = name => name.Contains(typeof(IPatchCustomerTrain).FullName!);

That serializes the mutation train's input and nothing else, which is the usual shape when inputs carry personal data and only the replayable ones are worth storing. Pair it with per-train metadata retention to decide how long each of them is kept.

Masking sensitive fields

Excluding a train drops its whole input or output. To keep the record but hide one field, mark the member with [TraxSensitive] (namespace Trax.Effect.Attributes):

public record ChargeCustomerInput(
    string CustomerId,
    [TraxSensitive] string CardNumber,
    Address BillingAddress
);
 
public class Address
{
    public string City { get; set; } = "";
 
    [TraxSensitive]
    public string Street { get; set; } = "";
}

The stored input then reads {"customerId": "c-1", "cardNumber": {"_redacted": true}, "billingAddress": {"city": "Leeds", "street": {"_redacted": true}}}. The train runs with the real values; only the stored copy is masked.

CaseWhat happens
A marked member on a nested object, or on each element of a collectionMasked where it sits; the rest of the object is kept
A marked member whose value is an object or a collectionThe whole value is replaced; nothing under it is written
A positional record parameterMark the parameter, with or without property:
[JsonPropertyName] on the memberMasked under its JSON name
A mark on a base property or an interface memberApplies to the override or implementation
A dictionary's keys or valuesNot masked: there is no member to mark
A member named Password with no markNot masked. Nothing is masked by name

The same masking applies to the junction output recorded by AddJunctionLogger(serializeJunctionData: true), and to the output handed to lifecycle hooks when SaveTrainParameters is off, so a broadcast or subscription never carries the value either. It does not apply to the copy a train is run from: a queued entry's input and a manifest's properties keep the real value, because the train needs it. Those copies are kept out of logs instead: a model's ToString(), the JSON effect and the junction logger write each as {"_omitted": true} (TraxLogSerialization.ForLogging(options) derives the options they use). The GraphQL operations reads that return them, workQueue.detail and manifestDetail, mask them on the way out the same way, as do the dashboard's work queue entry and dead letter pages, and requeueExecution and the dashboard's Re-queue refuse a recorded input that holds a mask (see Train inputs and the operations gate). A masked input cannot be deserialized back into the input type; TraxRedaction.ContainsRedaction(json) says whether a stored input or output holds a mask. Why it is opt-in and masked where it is written is recorded in effect/0010.

Returns

TBuilder, the same builder type that was passed in, for continued fluent chaining.

Examples

Basic usage (saves both inputs and outputs):

services.AddTrax(trax => trax
    .AddEffects(effects => effects
        .UsePostgres(connectionString)
        .SaveTrainParameters()
    )
);

Save only inputs (skip output serialization):

services.AddTrax(trax => trax
    .AddEffects(effects => effects
        .UsePostgres(connectionString)
        .SaveTrainParameters(configure: cfg =>
        {
            cfg.SaveInputs = true;
            cfg.SaveOutputs = false;
        })
    )
);

Custom JSON options with configuration:

services.AddTrax(trax => trax
    .AddEffects(effects => effects
        .UsePostgres(connectionString)
        .SaveTrainParameters(
            jsonSerializerOptions: new JsonSerializerOptions { WriteIndented = false },
            configure: cfg => cfg.SaveOutputs = false
        )
    )
);

Keep inputs, drop the output of a few known-large trains, and cap everything else at a ceiling of your own:

services.AddTrax(trax => trax
    .AddEffects(effects => effects
        .UsePostgres(connectionString)
        .SaveTrainParameters(configure: cfg =>
        {
            cfg.MaxParameterBytes = 512 * 1024;   // 512 KiB ceiling for every parameter
            cfg.ExcludeOutput<GetEntitiesQuery>();
            cfg.ExcludeOutput<GetLeadsQuery>();
            cfg.ExcludeOutput("GetPpaDataFromCache");   // string fragments work too
        })
    )
);

A train whose output crosses MaxParameterBytes stores {"_truncated": true, "_maxBytes": 524288} in Metadata.Output instead of the full payload. A train matched by ExcludeOutput stores nothing for its output, and its input is still serialized unless ExcludeInput or ShouldSaveInputs also refuses it.

A parameter that cannot be serialized at all stores {"_unserializable": true, "_error": "JsonException"} on the same principle, with the exception's type in _error. That covers a reference cycle, an unsupported type, a contract System.Text.Json rejects (two members with the same [JsonPropertyName], [JsonInclude] on a non-public member), and a property getter that throws. Only the exception's type is kept: the messages carry unbounded detail, which is the wrong thing to put in the column a ceiling exists to bound. The run itself is unaffected, because an output that cannot be stored is a recording problem rather than a reason to fail work that already succeeded.

Remarks

  • Requires a data provider to be registered (the serialized parameters are stored in the database via Metadata).
  • The serialized JSON is stored in Metadata.Input (set on train start) and Metadata.Output (set on completion).
  • Each object is serialized once. A run is saved at its start, around every junction and at its end, and the effect serializes an input or output only when the metadata is given an object it has not serialized yet. The stored input is therefore the input as it was when the run started: a train that mutates its input object afterwards does not change it.
  • Useful for debugging failed trains: inspect the exact input that caused the failure.
  • The ParameterEffectConfiguration singleton is accessible at runtime. The dashboard's Effects page provides a UI to toggle SaveInputs and SaveOutputs without restarting the application. The per-train exclusions and predicates are set in code and are not editable there; the global toggles are the outer gate, so turning SaveInputs off from the dashboard stops every train's input regardless of what the predicate says.
  • Lifecycle hooks receive the output under the same rules as the stored copy. When the output is stored, OnCompleted hooks read that copy, bounded by MaxParameterBytes. When it is not, the train serializes a copy for the hooks in memory, without persisting it, and follows the same decision: an output skipped by ExcludeOutput, ShouldSaveOutputs or SaveOutputs = false is not serialized for the hooks either, and they see Metadata.Output as null. Any copy that is built is bounded: by MaxParameterBytes, and when that is null by 1 MiB (DefaultLifecycleHookOutputPolicy.DefaultMaxCopyBytes), past which the hooks get the _truncated placeholder. The same 1 MiB ceiling applies on a host without SaveTrainParameters(), and when the effect is switched off from the dashboard. This matters because the broadcaster, GraphQL and SignalR hooks publish the copy to other processes and every subscriber.
  • MaxParameterBytes bounds serialization work, not the result object. It serializes through a streaming writer and aborts the moment the byte count crosses the ceiling, so an oversized collection or object graph is never fully materialized as a string. It does not shrink the train's return value itself, which is already resident in memory. For a train that genuinely returns tens of MB, prefer ExcludeOutput (skip serialization entirely) and reduce what the train returns.

Package

dotnet add package Trax.Effect.Provider.Parameter