Contain Observability Policy

contain-observability-policy

Summary

Logging, metrics, tracing, and privacy choices become inconsistent when every call site invents its own policy. Put observability decisions at owned boundaries so lower layers expose facts without duplicating policy.

Problem

Logging, metrics, tracing, sampling, redaction, severity, and correlation rules become hard to change when every helper invents its own observability policy. The code may emit many signals, but the signals are inconsistent and difficult to interpret together.

Preferred Move

Keep observability policy at owned boundaries: request handlers, job runners, CLI commands, workflow steps, persistence adapters, or domain services that own an operation. Lower helpers should return structured errors or events; the boundary decides how to log, count, trace, redact, or sample them.

Tradeoff

Some low-level code owns a real boundary, such as a database adapter or protocol parser. It can emit signals when it owns the operation being observed. Do not force all observability upward if doing so would lose the only useful context.

Agent Instruction

When adding logs, metrics, or spans, identify the boundary that owns the operation. Put policy there
and keep lower helpers focused on structured return values unless the helper owns an external
boundary.

Examples

Bad: a parser decides severity, wording, and metrics policy for an import workflow.

fn parse_record(row: CsvRow) -> Result<Record, ParseError> {
    if row.id().is_empty() {
        warn!("import row skipped: missing id");
        metrics::increment!("import.row.skipped");
        return Err(ParseError::MissingId);
    }

    Ok(Record::from(row))
}

Good: the parser returns meaning, and the import boundary owns observability policy.

fn parse_record(row: CsvRow) -> Result<Record, ParseError> {
    if row.id().is_empty() {
        return Err(ParseError::MissingId);
    }

    Ok(Record::from(row))
}

fn import_row(row: CsvRow, batch_id: BatchId) -> Result<(), ImportError> {
    let record = parse_record(row).map_err(|source| {
        warn!(%batch_id, error = %source, "import row rejected");
        metrics::increment!("import.row.rejected");
        ImportError::Rejected { batch_id, source }
    })?;

    save(record).map_err(|source| ImportError::Store { batch_id, source })
}

References

Source Use Note
OpenTelemetry concepts adapts Signals should be modeled consistently across telemetry.
OpenTelemetry log model adapts Log records have structured fields and severity.