Log Template Placeholder Unique

ID

csharp.log_template_placeholder_unique

Severity

high

Remediation Complexity

medium

Remediation Risk

low

Remediation Effort

medium

Resource

Code Smell

Language

CSharp

Tags

code_smell, logging, observability, structured-logging

Description

Reports a logging call whose message template uses the same named placeholder more than once. Two arguments are then handed to the provider under one property name, and at least one of them becomes unrecoverable from the log entry.

Rationale

A named placeholder is not a formatting device. It is the key under which the matching argument is stored in the log event, and that key is what makes the value searchable afterwards — filtering by an order id, grouping by a tenant, correlating two entries by a request id.

Using one name twice asks the provider to store two different values under the same key, which it cannot do. What happens instead is provider-specific: one keeps the last value and discards the earlier one, another keeps the first and ignores the rest, another writes a duplicated property and leaves the consuming tool to decide. In every case a value that the author intended to record is gone, and the reader cannot tell which of the two positions the surviving value came from. The usual origin is a template that was copied and edited while the argument list moved on independently.

Argument indexes are a different mechanism and are not reported: reusing an index is exactly how composite formatting repeats one value in several places, so it is intentional. Doubled braces are escapes and contain no placeholder. An alignment or format suffix is not part of the name, so two placeholders that differ only in their suffix do name the same property and are reported. Names are compared exactly, because the providers treat two spellings that differ in case as two properties. The name _ is excluded too — it is the conventional spelling for a value nobody intends to identify, so repeating it stores nothing anyone meant to retrieve later.

A template split across + is read as the one string the compiler folds it into, since long templates are routinely wrapped that way; a concatenation that includes anything other than string literals is not analysed, because its final text is not visible here. And a provider that formats with composite {0} placeholders rather than with message templates — log4net — is left out entirely: a {Name} hole there is ordinary text and names no property at all.

using Microsoft.Extensions.Logging;

public class Orders
{
    private readonly ILogger<Orders> _logger;

    public Orders(ILogger<Orders> logger) => _logger = logger;

    public void Repeated(int id, string action)
    {
        _logger.LogInformation("user {Id} did {Action} on {Id}", id, action, id);   // FLAW
    }

    public void RepeatedAcrossLines(int id, string action)
    {
        _logger.LogInformation("user {Id} did {Action} " + "on {Id}", id, action, id);   // FLAW
    }

    public void Discarded(int first, int second)
    {
        _logger.LogInformation("ignored {_} and {_}", first, second);   // OK, the discard identifies nothing
    }

    public void Distinct(int userId, string action, int orderId)
    {
        _logger.LogInformation("user {UserId} did {Action} on {OrderId}", userId, action, orderId);   // OK
    }

    public void ReusedIndex(int id)
    {
        _logger.LogInformation("id {0} seen again as {0}", id, id);   // OK, an index is reusable by design
    }

    public void Escaped(int id)
    {
        _logger.LogInformation("the literal {{Id}} plus the value {Id}", id);   // OK, doubled braces are escapes
    }
}

Remediation

Give each position its own name, chosen for what the value means rather than for the type it has — a template naming a user id and an order id separately is both correct and more useful to query than one that calls both of them the same thing. If the two positions really do hold the same value, write the placeholder once and let the message read around it, or use argument indexes if the value genuinely has to appear twice in the rendered text.

Where a repeated name survived because nobody noticed, check the argument list at the same time: a template edited without its arguments frequently ends up with a count mismatch too.