$payload The context data carried by the entry.
+ * @param Correlation|null $correlation The correlation bound to the logger, or null when none is.
* @return LogEntry The created instance.
*/
- public static function from(string $key, LogLevel $level, ?LogContext $context, array $payload): LogEntry
+ public static function of(string $key, LogLevel $level, array $payload, ?Correlation $correlation): LogEntry
{
- return new LogEntry(key: $key, level: $level, context: $context, payload: $payload);
+ return new LogEntry(key: $key, level: $level, payload: $payload, correlation: $correlation);
}
}
diff --git a/src/Logger.php b/src/Logger.php
index db06e0c..ccf6e1f 100644
--- a/src/Logger.php
+++ b/src/Logger.php
@@ -11,19 +11,19 @@
*
* Extends PSR-3 {@see LoggerInterface} to ensure compatibility with any PSR-3 consumer.
*
- * Implementations must support immutable context propagation: calling {@see withContext}
+ * Implementations must support immutable correlation propagation: calling {@see withCorrelation}
* returns a new instance without mutating the original.
*/
interface Logger extends LoggerInterface
{
/**
- * Creates a new Logger instance bound to the given correlation context.
+ * Creates a new Logger instance bound to the given correlation.
*
* The original instance remains unchanged. An implementation returns its own type, so
* declaring the concrete class name is the expected form.
*
- * @param LogContext $context The log context containing the correlation ID.
- * @return Logger A new Logger instance bound to the given context.
+ * @param Correlation $correlation The correlation containing the correlation ID.
+ * @return Logger A new Logger instance bound to the given correlation.
*/
- public function withContext(LogContext $context): Logger;
+ public function withCorrelation(Correlation $correlation): Logger;
}
diff --git a/src/Metrics/CloudWatch/EmbeddedMetricFormat.php b/src/Metrics/CloudWatch/EmbeddedMetricFormat.php
new file mode 100644
index 0000000..05146eb
--- /dev/null
+++ b/src/Metrics/CloudWatch/EmbeddedMetricFormat.php
@@ -0,0 +1,65 @@
+EMF publishes a metric by writing a log record that carries the metric inside it, which spares
+ * the publish API call. It never spares the custom metric tariff: every distinct pairing of metric
+ * name and dimension set is a series, and a series is billed. Which dimension names carry an
+ * unbounded number of values belongs to whoever emits, so this format refuses none until told which
+ * ones to refuse, and the refusal names the alternative.
+ *
+ * The record has to reach the log stream as a JSON object on its own line, because that is what
+ * the agent parses, and that is the line a telemetry logger writes for every metric.
+ */
+final readonly class EmbeddedMetricFormat implements MetricFormat
+{
+ private function __construct(private UnboundedDimensions $unboundedDimensions)
+ {
+ }
+
+ /**
+ * Creates the format refusing no dimension name.
+ *
+ * @return EmbeddedMetricFormat The created instance.
+ */
+ public static function default(): EmbeddedMetricFormat
+ {
+ return new EmbeddedMetricFormat(unboundedDimensions: UnboundedDimensions::none());
+ }
+
+ public function format(Metric $metric): array
+ {
+ $dimension = $this->unboundedDimensions->firstIn(dimensions: $metric->dimensions);
+
+ if (!is_null($dimension)) {
+ throw new UnboundedDimension(dimension: $dimension);
+ }
+
+ return new CloudWatchEnvelope(metric: $metric)->toPayload();
+ }
+
+ /**
+ * Returns a copy of the format refusing the given dimension names.
+ *
+ * Which names are unbounded is a property of the emitting domain and not of EMF, so the list
+ * is declared here rather than carried by the library.
+ *
+ * @param string ...$names The dimension names refused as unbounded.
+ * @return EmbeddedMetricFormat A copy refusing those names on top of the ones already refused.
+ */
+ public function withUnboundedDimensions(string ...$names): EmbeddedMetricFormat
+ {
+ return new EmbeddedMetricFormat(unboundedDimensions: $this->unboundedDimensions->and(...$names));
+ }
+}
diff --git a/src/Metrics/Metric.php b/src/Metrics/Metric.php
new file mode 100644
index 0000000..056d0d9
--- /dev/null
+++ b/src/Metrics/Metric.php
@@ -0,0 +1,165 @@
+A metric carries a namespace, a name, a value in a unit, the dimensions it is broken down by,
+ * and the fields that travel with it. What separates the two last ones is cost, not shape: a
+ * dimension is what a backend indexes and bills a series for, while a field rides along in the same
+ * record and stays queryable without multiplying anything. A granular attribute belongs in a
+ * field.
+ *
+ * Rendering is a {@see MetricFormat} concern. This type never knows which backend reads it.
+ */
+final readonly class Metric
+{
+ private function __construct(
+ public MetricName $name,
+ public MetricUnit $unit,
+ public int|float $value,
+ public array $fields,
+ public MetricNamespace $namespace,
+ public array $dimensions
+ ) {
+ $shared = array_key_first(array_intersect_key($fields, $dimensions));
+
+ if (!is_null($shared)) {
+ throw new DuplicateMetricIdentifier(identifier: (string)$shared);
+ }
+ }
+
+ /**
+ * Creates a metric counting one occurrence, with no dimension and no field.
+ *
+ * A measurement that is not a count derives from here through {@see Metric::withUnit()} and
+ * {@see Metric::withValue()}, so the only arguments a metric always takes are the two that
+ * identify it.
+ *
+ * @param string $name The metric name (e.g., OfferAccepted).
+ * @param string $namespace The namespace the series belongs to (e.g., Acme/Waitlist).
+ * @return Metric The created instance.
+ * @throws BlankMetricIdentifier If the name or the namespace carries nothing.
+ */
+ public static function of(string $name, string $namespace): Metric
+ {
+ return new Metric(
+ name: MetricName::from(value: $name),
+ unit: MetricUnit::COUNT,
+ value: 1,
+ fields: [],
+ namespace: MetricNamespace::from(value: $namespace),
+ dimensions: []
+ );
+ }
+
+ /**
+ * Returns a copy of the metric carrying no field at all.
+ *
+ * What survives a redaction is what the record should carry, so a caller that filters the
+ * fields rebuilds them from an empty one instead of writing over the originals: a field the
+ * filter dropped has no value to write, and would otherwise stay.
+ *
+ * @return Metric A copy with every field removed.
+ */
+ public function withoutFields(): Metric
+ {
+ return new Metric(
+ name: $this->name,
+ unit: $this->unit,
+ value: $this->value,
+ fields: [],
+ namespace: $this->namespace,
+ dimensions: $this->dimensions
+ );
+ }
+
+ /**
+ * Returns a copy of the metric expressed in another unit.
+ *
+ * @param MetricUnit $unit The unit the value is expressed in.
+ * @return Metric A copy with the unit set.
+ */
+ public function withUnit(MetricUnit $unit): Metric
+ {
+ return new Metric(
+ name: $this->name,
+ unit: $unit,
+ value: $this->value,
+ fields: $this->fields,
+ namespace: $this->namespace,
+ dimensions: $this->dimensions
+ );
+ }
+
+ /**
+ * Returns a copy of the metric carrying one more field in the same record.
+ *
+ * @param string $name The field name.
+ * @param string|int|float $value The field value.
+ * @return Metric A copy with the field set.
+ * @throws BlankMetricIdentifier If the field name carries nothing.
+ * @throws DuplicateMetricIdentifier If a dimension already carries that name.
+ */
+ public function withField(string $name, string|int|float $value): Metric
+ {
+ return new Metric(
+ name: $this->name,
+ unit: $this->unit,
+ value: $this->value,
+ fields: [...$this->fields, $name => MetricField::of(name: $name, value: $value)],
+ namespace: $this->namespace,
+ dimensions: $this->dimensions
+ );
+ }
+
+ /**
+ * Returns a copy of the metric with the measured value replaced.
+ *
+ * @param int|float $value The value the metric carries.
+ * @return Metric A copy with the value set.
+ */
+ public function withValue(int|float $value): Metric
+ {
+ return new Metric(
+ name: $this->name,
+ unit: $this->unit,
+ value: $value,
+ fields: $this->fields,
+ namespace: $this->namespace,
+ dimensions: $this->dimensions
+ );
+ }
+
+ /**
+ * Returns a copy of the metric broken down by one more dimension.
+ *
+ * Every distinct set of dimension values is a series of its own, so a dimension that takes an
+ * unbounded number of values multiplies what the backend stores and bills. A format refuses the
+ * names its consumer declares unbounded, and a granular attribute belongs in
+ * {@see Metric::withField()} instead.
+ *
+ * @param string $name The dimension name.
+ * @param string $value The dimension value.
+ * @return Metric A copy with the dimension set.
+ * @throws BlankMetricIdentifier If the dimension name carries nothing.
+ * @throws DuplicateMetricIdentifier If a field already carries that name.
+ */
+ public function withDimension(string $name, string $value): Metric
+ {
+ return new Metric(
+ name: $this->name,
+ unit: $this->unit,
+ value: $this->value,
+ fields: $this->fields,
+ namespace: $this->namespace,
+ dimensions: [...$this->dimensions, $name => MetricDimension::of(name: $name, value: $value)]
+ );
+ }
+}
diff --git a/src/Metrics/MetricDimension.php b/src/Metrics/MetricDimension.php
new file mode 100644
index 0000000..1117617
--- /dev/null
+++ b/src/Metrics/MetricDimension.php
@@ -0,0 +1,38 @@
+Every distinct set of dimension values is a series of its own, which a backend indexes and
+ * bills for, so a dimension is the expensive half of a record. A name that carries nothing declares
+ * an axis the backend cannot resolve, and the type refuses one.
+ */
+final readonly class MetricDimension
+{
+ private function __construct(public string $name, public string $value)
+ {
+ }
+
+ /**
+ * Creates a dimension from its name and its value.
+ *
+ * @param string $name The dimension name (e.g., plan).
+ * @param string $value The value it takes (e.g., saas).
+ * @return MetricDimension The created instance.
+ * @throws BlankMetricIdentifier If the name carries nothing.
+ */
+ public static function of(string $name, string $value): MetricDimension
+ {
+ if (trim($name) === '') {
+ throw new BlankMetricIdentifier(identifier: 'dimension name');
+ }
+
+ return new MetricDimension(name: $name, value: $value);
+ }
+}
diff --git a/src/Metrics/MetricField.php b/src/Metrics/MetricField.php
new file mode 100644
index 0000000..f3c6452
--- /dev/null
+++ b/src/Metrics/MetricField.php
@@ -0,0 +1,38 @@
+It is the cheap half: queryable where the record lands, and free of the cardinality that makes
+ * a dimension expensive, which is where a granular attribute belongs. A name that carries nothing
+ * leaves a value nobody can query for, and the type refuses one.
+ */
+final readonly class MetricField
+{
+ private function __construct(public string $name, public string|int|float $value)
+ {
+ }
+
+ /**
+ * Creates a field from its name and its value.
+ *
+ * @param string $name The field name (e.g., tenant_id).
+ * @param string|int|float $value The value it carries.
+ * @return MetricField The created instance.
+ * @throws BlankMetricIdentifier If the name carries nothing.
+ */
+ public static function of(string $name, string|int|float $value): MetricField
+ {
+ if (trim($name) === '') {
+ throw new BlankMetricIdentifier(identifier: 'field name');
+ }
+
+ return new MetricField(name: $name, value: $value);
+ }
+}
diff --git a/src/Metrics/MetricFormat.php b/src/Metrics/MetricFormat.php
new file mode 100644
index 0000000..4207594
--- /dev/null
+++ b/src/Metrics/MetricFormat.php
@@ -0,0 +1,26 @@
+The seam that keeps the metric itself free of any backend. An implementation owns the shape
+ * of the record, the spelling of the units, and whichever dimension names it refuses, and lives in
+ * the folder of the backend it writes for.
+ */
+interface MetricFormat
+{
+ /**
+ * Renders the metric as the payload to be logged.
+ *
+ * @param Metric $metric The metric to render.
+ * @return array The payload the backend reads.
+ * @throws UnboundedDimension If the metric declares a dimension the format refuses.
+ */
+ public function format(Metric $metric): array;
+}
diff --git a/src/Metrics/MetricName.php b/src/Metrics/MetricName.php
new file mode 100644
index 0000000..8cf9a2a
--- /dev/null
+++ b/src/Metrics/MetricName.php
@@ -0,0 +1,37 @@
+It names the series and, in a record that carries the metric inside it, it is also the key the
+ * value sits at. A name that carries nothing leaves a record whose value has no series to belong
+ * to, so the type refuses one rather than let the emptiness travel.
+ */
+final readonly class MetricName
+{
+ private function __construct(public string $value)
+ {
+ }
+
+ /**
+ * Creates a name from its text.
+ *
+ * @param string $value The metric name (e.g., OfferAccepted).
+ * @return MetricName The created instance.
+ * @throws BlankMetricIdentifier If the text carries nothing.
+ */
+ public static function from(string $value): MetricName
+ {
+ if (trim($value) === '') {
+ throw new BlankMetricIdentifier(identifier: 'name');
+ }
+
+ return new MetricName(value: $value);
+ }
+}
diff --git a/src/Metrics/MetricNamespace.php b/src/Metrics/MetricNamespace.php
new file mode 100644
index 0000000..a3da37a
--- /dev/null
+++ b/src/Metrics/MetricNamespace.php
@@ -0,0 +1,36 @@
+It is what separates one application's series from another's in the same backend, so a
+ * namespace that carries nothing puts the series where no one is looking. The type refuses one.
+ */
+final readonly class MetricNamespace
+{
+ private function __construct(public string $value)
+ {
+ }
+
+ /**
+ * Creates a namespace from its text.
+ *
+ * @param string $value The namespace (e.g., Acme/Waitlist).
+ * @return MetricNamespace The created instance.
+ * @throws BlankMetricIdentifier If the text carries nothing.
+ */
+ public static function from(string $value): MetricNamespace
+ {
+ if (trim($value) === '') {
+ throw new BlankMetricIdentifier(identifier: 'namespace');
+ }
+
+ return new MetricNamespace(value: $value);
+ }
+}
diff --git a/src/Metrics/MetricUnit.php b/src/Metrics/MetricUnit.php
new file mode 100644
index 0000000..234a6a0
--- /dev/null
+++ b/src/Metrics/MetricUnit.php
@@ -0,0 +1,24 @@
+Base units only, which is what the neutral specifications prescribe: OpenTelemetry asks for
+ * non-prefixed units and for durations in seconds, and OpenMetrics asks for base units too. A
+ * duration of half a second is `0.5` in {@see MetricUnit::SECONDS}, never `500` in a millisecond
+ * unit that does not exist here. A backend that spells them its own way translates in its
+ * {@see MetricFormat}, and every backend can express these six.
+ */
+enum MetricUnit: string
+{
+ case BITS = 'bit';
+ case NONE = '1';
+ case BYTES = 'By';
+ case COUNT = '{count}';
+ case PERCENT = '%';
+ case SECONDS = 's';
+}
diff --git a/src/Redactions/BirthDateRedaction.php b/src/Redactions/BirthDateRedaction.php
new file mode 100644
index 0000000..4f9a7e4
--- /dev/null
+++ b/src/Redactions/BirthDateRedaction.php
@@ -0,0 +1,68 @@
+A full date of birth identifies a person almost as well as a document does, while the year
+ * alone answers most of what a reader needs: whether the subject is a minor, which cohort a metric
+ * belongs to. The default keeps the four leading characters, which is the year of a date written as
+ * ISO 8601, and the separators survive so the shape still reads as a date. For a date written the
+ * other way around, name the window that keeps what you meant to keep.
+ *
+ * The same value travels under more than one name, so the default covers birth*date
+ * and date_of_birth, however they are spelled.
+ */
+final readonly class BirthDateRedaction implements Redaction
+{
+ private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 4;
+
+ private const array DEFAULT_FIELDS = ['birth*date', 'date_of_birth'];
+
+ private Redaction $redactor;
+
+ private function __construct(array $fields, int $visiblePrefixLength)
+ {
+ $this->redactor = GenericRedaction::masking(
+ mask: Mask::preservingSeparators(),
+ fields: $fields,
+ visibility: Visibility::edges(prefixLength: $visiblePrefixLength)
+ );
+ }
+
+ /**
+ * Creates a BirthDateRedaction from the fields to mask and the number of visible leading characters.
+ *
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
+ * @param int|null $visiblePrefixLength Leading characters left visible, or null for the default.
+ * @return BirthDateRedaction The created instance.
+ * @throws NegativeVisibleLength If the visible prefix length is negative.
+ */
+ public static function from(array $fields, ?int $visiblePrefixLength = null): BirthDateRedaction
+ {
+ return new BirthDateRedaction(
+ fields: $fields,
+ visiblePrefixLength: ($visiblePrefixLength ?? self::DEFAULT_VISIBLE_PREFIX_LENGTH)
+ );
+ }
+
+ /**
+ * Builds a BirthDateRedaction covering the names a date of birth travels under.
+ *
+ * @return BirthDateRedaction The created instance.
+ */
+ public static function default(): BirthDateRedaction
+ {
+ return BirthDateRedaction::from(fields: self::DEFAULT_FIELDS);
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/DocumentRedaction.php b/src/Redactions/DocumentRedaction.php
index 7d689c2..e009c5a 100644
--- a/src/Redactions/DocumentRedaction.php
+++ b/src/Redactions/DocumentRedaction.php
@@ -5,25 +5,22 @@
namespace TinyBlocks\Logger\Redactions;
use TinyBlocks\Logger\Exceptions\NegativeVisibleLength;
-use TinyBlocks\Logger\Mask;
-use TinyBlocks\Logger\Redaction;
-use TinyBlocks\Logger\Redactions\Rules\VisibleEdgesRedaction;
/**
* Masks document field values, keeping a configurable number of trailing characters visible.
*/
final readonly class DocumentRedaction implements Redaction
{
- private const int DEFAULT_VISIBLE_SUFFIX_LENGTH = 3;
+ private const int DEFAULT_VISIBLE_SUFFIX_LENGTH = 2;
private Redaction $redactor;
private function __construct(array $fields, int $visibleSuffixLength)
{
- $this->redactor = VisibleEdgesRedaction::from(
+ $this->redactor = GenericRedaction::masking(
mask: Mask::proportional(),
fields: $fields,
- visibleSuffixLength: $visibleSuffixLength
+ visibility: Visibility::edges(suffixLength: $visibleSuffixLength)
);
}
@@ -31,13 +28,16 @@ private function __construct(array $fields, int $visibleSuffixLength)
* Creates a DocumentRedaction from the fields to mask and the number of visible trailing characters.
*
* @param string[] $fields The field names whose values are masked, wildcards accepted.
- * @param int $visibleSuffixLength The number of trailing characters left visible.
+ * @param int|null $visibleSuffixLength Trailing characters left visible, or null for the default.
* @return DocumentRedaction The created instance.
* @throws NegativeVisibleLength If the visible suffix length is negative.
*/
- public static function from(array $fields, int $visibleSuffixLength): DocumentRedaction
+ public static function from(array $fields, ?int $visibleSuffixLength = null): DocumentRedaction
{
- return new DocumentRedaction(fields: $fields, visibleSuffixLength: $visibleSuffixLength);
+ return new DocumentRedaction(
+ fields: $fields,
+ visibleSuffixLength: ($visibleSuffixLength ?? self::DEFAULT_VISIBLE_SUFFIX_LENGTH)
+ );
}
/**
@@ -47,7 +47,7 @@ public static function from(array $fields, int $visibleSuffixLength): DocumentRe
*/
public static function default(): DocumentRedaction
{
- return DocumentRedaction::from(fields: ['document'], visibleSuffixLength: self::DEFAULT_VISIBLE_SUFFIX_LENGTH);
+ return DocumentRedaction::from(fields: ['document']);
}
public function redact(array $payload): array
diff --git a/src/Redactions/EmailRedaction.php b/src/Redactions/EmailRedaction.php
index e35700d..b919f15 100644
--- a/src/Redactions/EmailRedaction.php
+++ b/src/Redactions/EmailRedaction.php
@@ -5,12 +5,6 @@
namespace TinyBlocks\Logger\Redactions;
use TinyBlocks\Logger\Exceptions\NegativeVisibleLength;
-use TinyBlocks\Logger\Internal\Redactor\FieldMatcher;
-use TinyBlocks\Logger\Internal\Redactor\Redactor;
-use TinyBlocks\Logger\Internal\Redactor\VisibleEdges;
-use TinyBlocks\Logger\Internal\Redactor\VisibleLocalPart;
-use TinyBlocks\Logger\Mask;
-use TinyBlocks\Logger\Redaction;
/**
* Masks the local part of email field values, keeping a configurable visible prefix and the domain.
@@ -23,15 +17,10 @@
private function __construct(array $fields, int $visiblePrefixLength)
{
- $mask = Mask::proportional();
- $localPart = new VisibleLocalPart(
- mask: $mask,
- localPart: new VisibleEdges(mask: $mask, prefixLength: $visiblePrefixLength, suffixLength: 0)
- );
-
- $this->redactor = new Redactor(
- fields: new FieldMatcher(fields: $fields),
- maskingFunction: $localPart->applyTo(...)
+ $this->redactor = GenericRedaction::masking(
+ mask: Mask::proportional(),
+ fields: $fields,
+ visibility: Visibility::localPart(prefixLength: $visiblePrefixLength)
);
}
@@ -39,13 +28,16 @@ private function __construct(array $fields, int $visiblePrefixLength)
* Creates an EmailRedaction from the fields to mask and the number of visible leading characters.
*
* @param string[] $fields The field names whose values are masked, wildcards accepted.
- * @param int $visiblePrefixLength The number of leading characters of the local part left visible.
+ * @param int|null $visiblePrefixLength Leading characters of the local part visible, or null for the default.
* @return EmailRedaction The created instance.
* @throws NegativeVisibleLength If the visible prefix length is negative.
*/
- public static function from(array $fields, int $visiblePrefixLength): EmailRedaction
+ public static function from(array $fields, ?int $visiblePrefixLength = null): EmailRedaction
{
- return new EmailRedaction(fields: $fields, visiblePrefixLength: $visiblePrefixLength);
+ return new EmailRedaction(
+ fields: $fields,
+ visiblePrefixLength: ($visiblePrefixLength ?? self::DEFAULT_VISIBLE_PREFIX_LENGTH)
+ );
}
/**
@@ -55,7 +47,7 @@ public static function from(array $fields, int $visiblePrefixLength): EmailRedac
*/
public static function default(): EmailRedaction
{
- return EmailRedaction::from(fields: ['email'], visiblePrefixLength: self::DEFAULT_VISIBLE_PREFIX_LENGTH);
+ return EmailRedaction::from(fields: ['email']);
}
public function redact(array $payload): array
diff --git a/src/Redactions/FilterExpressionRedaction.php b/src/Redactions/FilterExpressionRedaction.php
new file mode 100644
index 0000000..e367f4f
--- /dev/null
+++ b/src/Redactions/FilterExpressionRedaction.php
@@ -0,0 +1,43 @@
+A filter arrives as one string, so the field names inside it are out of reach of any field
+ * based strategy, and the operand is where the sensitive value sits. Written for the comparison
+ * syntax RSQL and FIQL share, where status==active and document=in=(...)
+ * are the shapes a query takes, including the parenthesized list of an =in= comparison.
+ * What is asked stays readable, what is asked about does not.
+ */
+final readonly class FilterExpressionRedaction implements Redaction
+{
+ private const string PATTERN = '/([^;,()=!<>\s]+(?:==|!=|=[a-z]{2,4}=))(\([^)]*\)|[^;,()\s]*)/i';
+
+ private const string REPLACEMENT = '${1}********';
+
+ private Redaction $redactor;
+
+ private function __construct()
+ {
+ $this->redactor = GenericRedaction::replacing(pattern: self::PATTERN, replacement: self::REPLACEMENT);
+ }
+
+ /**
+ * Builds a FilterExpressionRedaction masking the operand of every comparison it finds.
+ *
+ * @return FilterExpressionRedaction The created instance.
+ */
+ public static function default(): FilterExpressionRedaction
+ {
+ return new FilterExpressionRedaction();
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/GenericRedaction.php b/src/Redactions/GenericRedaction.php
new file mode 100644
index 0000000..b4bfc21
--- /dev/null
+++ b/src/Redactions/GenericRedaction.php
@@ -0,0 +1,119 @@
+The strategies named after a kind of data are this one with the decisions already made: which
+ * fields are covered, which mask renders them, and how much of the value survives. Reach for this
+ * one whenever none of them fits, and for whatever they cannot express: dropping a field, keeping
+ * an allow list, rewriting matched text, or narrowing another redaction to one branch.
+ */
+final readonly class GenericRedaction implements Redaction
+{
+ private function __construct(private Redaction $redactor)
+ {
+ }
+
+ /**
+ * Creates a GenericRedaction applying another redaction only under the given parent field.
+ *
+ * @param string $parent The parent field name whose sub payloads the redaction reaches.
+ * @param Redaction $redaction The redaction applied within that branch.
+ * @return GenericRedaction The created instance.
+ */
+ public static function under(string $parent, Redaction $redaction): GenericRedaction
+ {
+ return new GenericRedaction(
+ redactor: new ScopedRedactor(scope: new FieldMatcher(fields: [$parent]), redaction: $redaction)
+ );
+ }
+
+ /**
+ * Creates a GenericRedaction masking every value whose field is not on the allow list.
+ *
+ * The inverse of naming what to hide. Naming what to keep removes the leak by omission: a
+ * field added later is masked until it is explicitly allowed.
+ *
+ * @param string[] $fields The field names left untouched, wildcards accepted.
+ * @param Mask|null $mask The strategy rendering every value outside the allow list, or null for a fixed mask.
+ * @return GenericRedaction The created instance.
+ */
+ public static function keeping(array $fields, ?Mask $mask = null): GenericRedaction
+ {
+ $rendering = ($mask ?? Mask::fixed());
+
+ return new GenericRedaction(
+ redactor: new VisibleFieldRedactor(
+ fields: new FieldMatcher(fields: $fields),
+ maskingFunction: $rendering->applyTo(...)
+ )
+ );
+ }
+
+ /**
+ * Creates a GenericRedaction masking the given fields, at any depth.
+ *
+ * @param Mask $mask The strategy rendering the hidden portion of each value.
+ * @param string[] $fields The field names covered, wildcards accepted.
+ * @param Visibility|null $visibility How much of each value survives, or null to hide it whole.
+ * @return GenericRedaction The created instance.
+ */
+ public static function masking(Mask $mask, array $fields, ?Visibility $visibility = null): GenericRedaction
+ {
+ $visible = ($visibility ?? Visibility::none());
+
+ return new GenericRedaction(
+ redactor: new Redactor(
+ fields: new FieldMatcher(fields: $fields),
+ maskingFunction: static fn(string $value): string => $visible->applyTo(mask: $mask, value: $value)
+ )
+ );
+ }
+
+ /**
+ * Creates a GenericRedaction dropping the given fields, at any depth.
+ *
+ * The key itself is gone from the output, which is what a value with no meaning once masked
+ * deserves: a stack trace, a payment code, a raw user agent.
+ *
+ * @param string[] $fields The field names removed, wildcards accepted.
+ * @return GenericRedaction The created instance.
+ */
+ public static function removing(array $fields): GenericRedaction
+ {
+ return new GenericRedaction(redactor: new FieldRemover(fields: new FieldMatcher(fields: $fields)));
+ }
+
+ /**
+ * Creates a GenericRedaction rewriting every match of the pattern, in every value.
+ *
+ * Field names are not consulted, which is what reaches sensitive data embedded in free text:
+ * an exception message quoting a document, a URL carrying a token.
+ *
+ * @param string $pattern The regular expression matched against every string value.
+ * @param string $replacement The replacement, which may reference capture groups.
+ * @return GenericRedaction The created instance.
+ * @throws MalformedRedactionPattern If the pattern is not a valid regular expression.
+ */
+ public static function replacing(string $pattern, string $replacement): GenericRedaction
+ {
+ return new GenericRedaction(redactor: new PatternRedactor(pattern: $pattern, replacement: $replacement));
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Mask.php b/src/Redactions/Mask.php
similarity index 89%
rename from src/Mask.php
rename to src/Redactions/Mask.php
index 6d05976..c57ca37 100644
--- a/src/Mask.php
+++ b/src/Redactions/Mask.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\Logger;
+namespace TinyBlocks\Logger\Redactions;
use TinyBlocks\Logger\Internal\Redactor\MaskStyle;
@@ -15,6 +15,8 @@
*/
final readonly class Mask
{
+ private const int DEFAULT_LENGTH = 8;
+
private function __construct(private MaskStyle $style, private string $fixedMask = '')
{
}
@@ -25,10 +27,10 @@ private function __construct(private MaskStyle $style, private string $fixedMask
* The length of the original value is never revealed, and the mask is emitted even when
* nothing was hidden, so a short value cannot be told apart from a long one.
*
- * @param int $length The number of mask characters emitted.
+ * @param int $length The number of mask characters emitted, eight when not given.
* @return Mask The created instance.
*/
- public static function fixed(int $length): Mask
+ public static function fixed(int $length = self::DEFAULT_LENGTH): Mask
{
return new Mask(style: MaskStyle::FIXED, fixedMask: MaskStyle::fixedMaskOf(length: $length));
}
diff --git a/src/Redactions/NameRedaction.php b/src/Redactions/NameRedaction.php
index 21d9c99..015c296 100644
--- a/src/Redactions/NameRedaction.php
+++ b/src/Redactions/NameRedaction.php
@@ -5,9 +5,6 @@
namespace TinyBlocks\Logger\Redactions;
use TinyBlocks\Logger\Exceptions\NegativeVisibleLength;
-use TinyBlocks\Logger\Mask;
-use TinyBlocks\Logger\Redaction;
-use TinyBlocks\Logger\Redactions\Rules\VisibleEdgesRedaction;
/**
* Masks name field values, keeping a configurable number of leading characters visible.
@@ -20,10 +17,10 @@
private function __construct(array $fields, int $visiblePrefixLength)
{
- $this->redactor = VisibleEdgesRedaction::from(
+ $this->redactor = GenericRedaction::masking(
mask: Mask::proportional(),
fields: $fields,
- visiblePrefixLength: $visiblePrefixLength
+ visibility: Visibility::edges(prefixLength: $visiblePrefixLength)
);
}
@@ -31,13 +28,16 @@ private function __construct(array $fields, int $visiblePrefixLength)
* Creates a NameRedaction from the fields to mask and the number of visible leading characters.
*
* @param string[] $fields The field names whose values are masked, wildcards accepted.
- * @param int $visiblePrefixLength The number of leading characters left visible.
+ * @param int|null $visiblePrefixLength Leading characters left visible, or null for the default.
* @return NameRedaction The created instance.
* @throws NegativeVisibleLength If the visible prefix length is negative.
*/
- public static function from(array $fields, int $visiblePrefixLength): NameRedaction
+ public static function from(array $fields, ?int $visiblePrefixLength = null): NameRedaction
{
- return new NameRedaction(fields: $fields, visiblePrefixLength: $visiblePrefixLength);
+ return new NameRedaction(
+ fields: $fields,
+ visiblePrefixLength: ($visiblePrefixLength ?? self::DEFAULT_VISIBLE_PREFIX_LENGTH)
+ );
}
/**
@@ -47,7 +47,7 @@ public static function from(array $fields, int $visiblePrefixLength): NameRedact
*/
public static function default(): NameRedaction
{
- return NameRedaction::from(fields: ['name'], visiblePrefixLength: self::DEFAULT_VISIBLE_PREFIX_LENGTH);
+ return NameRedaction::from(fields: ['name']);
}
public function redact(array $payload): array
diff --git a/src/Redactions/PasswordRedaction.php b/src/Redactions/PasswordRedaction.php
deleted file mode 100644
index ab49e0f..0000000
--- a/src/Redactions/PasswordRedaction.php
+++ /dev/null
@@ -1,53 +0,0 @@
-redactor = FullMaskRedaction::from(mask: Mask::fixed(length: $fixedMaskLength), fields: $fields);
- }
-
- /**
- * Creates a PasswordRedaction from the fields to mask and the fixed mask length.
- *
- * @param string[] $fields The field names whose values are masked, wildcards accepted.
- * @param int $fixedMaskLength The fixed number of mask characters emitted.
- * @return PasswordRedaction The created instance.
- */
- public static function from(
- array $fields,
- int $fixedMaskLength = self::DEFAULT_FIXED_MASK_LENGTH
- ): PasswordRedaction {
- return new PasswordRedaction(fields: $fields, fixedMaskLength: $fixedMaskLength);
- }
-
- /**
- * Builds a PasswordRedaction with the default password field and fixed mask length.
- *
- * @return PasswordRedaction The created instance.
- */
- public static function default(): PasswordRedaction
- {
- return PasswordRedaction::from(fields: ['password']);
- }
-
- public function redact(array $payload): array
- {
- return $this->redactor->redact(payload: $payload);
- }
-}
diff --git a/src/Redactions/PhoneRedaction.php b/src/Redactions/PhoneRedaction.php
index 3c2324a..4546875 100644
--- a/src/Redactions/PhoneRedaction.php
+++ b/src/Redactions/PhoneRedaction.php
@@ -5,9 +5,6 @@
namespace TinyBlocks\Logger\Redactions;
use TinyBlocks\Logger\Exceptions\NegativeVisibleLength;
-use TinyBlocks\Logger\Mask;
-use TinyBlocks\Logger\Redaction;
-use TinyBlocks\Logger\Redactions\Rules\VisibleEdgesRedaction;
/**
* Masks phone field values, keeping a configurable number of trailing characters visible.
@@ -20,10 +17,10 @@
private function __construct(array $fields, int $visibleSuffixLength)
{
- $this->redactor = VisibleEdgesRedaction::from(
+ $this->redactor = GenericRedaction::masking(
mask: Mask::proportional(),
fields: $fields,
- visibleSuffixLength: $visibleSuffixLength
+ visibility: Visibility::edges(suffixLength: $visibleSuffixLength)
);
}
@@ -31,13 +28,16 @@ private function __construct(array $fields, int $visibleSuffixLength)
* Creates a PhoneRedaction from the fields to mask and the number of visible trailing characters.
*
* @param string[] $fields The field names whose values are masked, wildcards accepted.
- * @param int $visibleSuffixLength The number of trailing characters left visible.
+ * @param int|null $visibleSuffixLength Trailing characters left visible, or null for the default.
* @return PhoneRedaction The created instance.
* @throws NegativeVisibleLength If the visible suffix length is negative.
*/
- public static function from(array $fields, int $visibleSuffixLength): PhoneRedaction
+ public static function from(array $fields, ?int $visibleSuffixLength = null): PhoneRedaction
{
- return new PhoneRedaction(fields: $fields, visibleSuffixLength: $visibleSuffixLength);
+ return new PhoneRedaction(
+ fields: $fields,
+ visibleSuffixLength: ($visibleSuffixLength ?? self::DEFAULT_VISIBLE_SUFFIX_LENGTH)
+ );
}
/**
@@ -47,7 +47,7 @@ public static function from(array $fields, int $visibleSuffixLength): PhoneRedac
*/
public static function default(): PhoneRedaction
{
- return PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: self::DEFAULT_VISIBLE_SUFFIX_LENGTH);
+ return PhoneRedaction::from(fields: ['phone']);
}
public function redact(array $payload): array
diff --git a/src/Redactions/PostalCodeRedaction.php b/src/Redactions/PostalCodeRedaction.php
new file mode 100644
index 0000000..3072795
--- /dev/null
+++ b/src/Redactions/PostalCodeRedaction.php
@@ -0,0 +1,67 @@
+A full postal code narrows to a street, and sometimes to a building. The leading characters
+ * name a region and are what a reader needs to tell one market from another, so the default keeps
+ * three of them and hides the rest, with the separators preserved.
+ *
+ * The same value travels under more than one name, so the default covers the postal code and the
+ * zip code alike, however they are spelled: post*code and zip*code. A bare
+ * zip is left out, since a field by that name is as likely to carry an archive.
+ */
+final readonly class PostalCodeRedaction implements Redaction
+{
+ private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 3;
+
+ private const array DEFAULT_FIELDS = ['post*code', 'zip*code'];
+
+ private Redaction $redactor;
+
+ private function __construct(array $fields, int $visiblePrefixLength)
+ {
+ $this->redactor = GenericRedaction::masking(
+ mask: Mask::preservingSeparators(),
+ fields: $fields,
+ visibility: Visibility::edges(prefixLength: $visiblePrefixLength)
+ );
+ }
+
+ /**
+ * Creates a PostalCodeRedaction from the fields to mask and the number of visible leading characters.
+ *
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
+ * @param int|null $visiblePrefixLength Leading characters left visible, or null for the default.
+ * @return PostalCodeRedaction The created instance.
+ * @throws NegativeVisibleLength If the visible prefix length is negative.
+ */
+ public static function from(array $fields, ?int $visiblePrefixLength = null): PostalCodeRedaction
+ {
+ return new PostalCodeRedaction(
+ fields: $fields,
+ visiblePrefixLength: ($visiblePrefixLength ?? self::DEFAULT_VISIBLE_PREFIX_LENGTH)
+ );
+ }
+
+ /**
+ * Builds a PostalCodeRedaction covering the names a postal code travels under.
+ *
+ * @return PostalCodeRedaction The created instance.
+ */
+ public static function default(): PostalCodeRedaction
+ {
+ return PostalCodeRedaction::from(fields: self::DEFAULT_FIELDS);
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/QueryParametersRedaction.php b/src/Redactions/QueryParametersRedaction.php
new file mode 100644
index 0000000..640301f
--- /dev/null
+++ b/src/Redactions/QueryParametersRedaction.php
@@ -0,0 +1,57 @@
+A query parameter is a value someone put in a URL, so it carries whatever the caller sent: an
+ * identifier, a document, a token. Naming what stays readable removes the leak by omission, since a
+ * parameter added later is masked until it is allowed.
+ *
+ * It reaches the query_parameters branch, which is where the request log middleware
+ * of this ecosystem puts them. For a payload that carries them under another name, compose
+ * {@see GenericRedaction::under()} with {@see GenericRedaction::keeping()} instead.
+ */
+final readonly class QueryParametersRedaction implements Redaction
+{
+ private const string PARENT = 'query_parameters';
+
+ private Redaction $redactor;
+
+ private function __construct(array $fields)
+ {
+ $this->redactor = GenericRedaction::under(
+ parent: self::PARENT,
+ redaction: GenericRedaction::keeping(fields: $fields)
+ );
+ }
+
+ /**
+ * Creates a QueryParametersRedaction keeping only the named parameters readable.
+ *
+ * @param string[] $fields The parameter names left untouched, wildcards accepted.
+ * @return QueryParametersRedaction The created instance.
+ */
+ public static function keeping(array $fields): QueryParametersRedaction
+ {
+ return new QueryParametersRedaction(fields: $fields);
+ }
+
+ /**
+ * Builds a QueryParametersRedaction masking every query parameter.
+ *
+ * @return QueryParametersRedaction The created instance.
+ */
+ public static function default(): QueryParametersRedaction
+ {
+ return QueryParametersRedaction::keeping(fields: []);
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/QueryStringRedaction.php b/src/Redactions/QueryStringRedaction.php
new file mode 100644
index 0000000..01dc8ac
--- /dev/null
+++ b/src/Redactions/QueryStringRedaction.php
@@ -0,0 +1,41 @@
+A URL reaches the log as an ordinary string, so no field name covers what rides after the
+ * question mark: an identifier, a token, a filter carrying a document. The path is what a reader
+ * needs to know which route was called, and it survives.
+ */
+final readonly class QueryStringRedaction implements Redaction
+{
+ private const string PATTERN = '/(\?)[^\s]*/';
+
+ private const string REPLACEMENT = '${1}';
+
+ private Redaction $redactor;
+
+ private function __construct()
+ {
+ $this->redactor = GenericRedaction::replacing(pattern: self::PATTERN, replacement: self::REPLACEMENT);
+ }
+
+ /**
+ * Builds a QueryStringRedaction dropping everything after the question mark of every URL.
+ *
+ * @return QueryStringRedaction The created instance.
+ */
+ public static function default(): QueryStringRedaction
+ {
+ return new QueryStringRedaction();
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redaction.php b/src/Redactions/Redaction.php
similarity index 93%
rename from src/Redaction.php
rename to src/Redactions/Redaction.php
index e74253a..a5adde6 100644
--- a/src/Redaction.php
+++ b/src/Redactions/Redaction.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace TinyBlocks\Logger;
+namespace TinyBlocks\Logger\Redactions;
/**
* Defines the contract for redacting sensitive information from structured log data.
diff --git a/src/Redactions/Rules/AllowedFieldsRedaction.php b/src/Redactions/Rules/AllowedFieldsRedaction.php
deleted file mode 100644
index 5272284..0000000
--- a/src/Redactions/Rules/AllowedFieldsRedaction.php
+++ /dev/null
@@ -1,48 +0,0 @@
-The inverse of the other strategies, which name what to hide. Naming what to keep removes the
- * leak by omission: a field added later is masked until it is explicitly allowed. Pair it with
- * {@see ScopedRedaction} to apply the allow list to one branch of the payload instead of all of
- * it.
- */
-final readonly class AllowedFieldsRedaction implements Redaction
-{
- private Redaction $redactor;
-
- private function __construct(Mask $mask, array $fields)
- {
- $this->redactor = new RetainedFieldRedactor(
- fields: new FieldMatcher(fields: $fields),
- maskingFunction: $mask->applyTo(...)
- );
- }
-
- /**
- * Creates an AllowedFieldsRedaction from the mask and the fields left untouched.
- *
- * @param Mask $mask The strategy rendering every value outside the allow list.
- * @param string[] $fields The field names left untouched, wildcards accepted.
- * @return AllowedFieldsRedaction The created instance.
- */
- public static function from(Mask $mask, array $fields): AllowedFieldsRedaction
- {
- return new AllowedFieldsRedaction(mask: $mask, fields: $fields);
- }
-
- public function redact(array $payload): array
- {
- return $this->redactor->redact(payload: $payload);
- }
-}
diff --git a/src/Redactions/Rules/FullMaskRedaction.php b/src/Redactions/Rules/FullMaskRedaction.php
deleted file mode 100644
index f2e1cb8..0000000
--- a/src/Redactions/Rules/FullMaskRedaction.php
+++ /dev/null
@@ -1,76 +0,0 @@
-The strategy for values that carry no operational meaning once logged, such as secrets, free
- * text, network addresses, and user agents.
- */
-final readonly class FullMaskRedaction implements Redaction
-{
- private const int SECRET_MASK_LENGTH = 8;
-
- private const array COMMON_SECRET_FIELDS = [
- '*token*',
- '*secret*',
- '*api_key*',
- '*password*',
- 'credentials',
- 'authorization',
- '*private_key*'
- ];
-
- private Redaction $redactor;
-
- private function __construct(Mask $mask, array $fields)
- {
- $this->redactor = new Redactor(
- fields: new FieldMatcher(fields: $fields),
- maskingFunction: $mask->applyTo(...)
- );
- }
-
- /**
- * Creates a FullMaskRedaction from the mask and the fields to redact.
- *
- * @param Mask $mask The strategy rendering the masked value.
- * @param string[] $fields The field names whose values are masked, wildcards accepted.
- * @return FullMaskRedaction The created instance.
- */
- public static function from(Mask $mask, array $fields): FullMaskRedaction
- {
- return new FullMaskRedaction(mask: $mask, fields: $fields);
- }
-
- /**
- * Builds a FullMaskRedaction covering the field names that carry secrets across most systems.
- *
- * Matches every field whose name contains token, secret,
- * api_key, password, or private_key, plus
- * credentials and authorization. The mask has a fixed width, so the
- * length of the secret is never revealed.
- *
- * @return FullMaskRedaction The created instance.
- */
- public static function commonSecrets(): FullMaskRedaction
- {
- return FullMaskRedaction::from(
- mask: Mask::fixed(length: self::SECRET_MASK_LENGTH),
- fields: self::COMMON_SECRET_FIELDS
- );
- }
-
- public function redact(array $payload): array
- {
- return $this->redactor->redact(payload: $payload);
- }
-}
diff --git a/src/Redactions/Rules/PatternRedaction.php b/src/Redactions/Rules/PatternRedaction.php
deleted file mode 100644
index af7da06..0000000
--- a/src/Redactions/Rules/PatternRedaction.php
+++ /dev/null
@@ -1,44 +0,0 @@
-Field-based strategies cannot reach sensitive data embedded in free text: an exception message
- * quoting a document, a stack trace, a URI carrying a query string. Matching on the value instead of
- * the key closes that gap.
- */
-final readonly class PatternRedaction implements Redaction
-{
- private Redaction $redactor;
-
- private function __construct(string $pattern, string $replacement)
- {
- $this->redactor = new PatternRedactor(pattern: $pattern, replacement: $replacement);
- }
-
- /**
- * Creates a PatternRedaction from the pattern to match and the text replacing every match.
- *
- * @param string $pattern The regular expression matched against every string value.
- * @param string $replacement The text every match is replaced with.
- * @return PatternRedaction The created instance.
- * @throws InvalidRedactionPattern If the regular expression engine rejects the pattern.
- */
- public static function from(string $pattern, string $replacement): PatternRedaction
- {
- return new PatternRedaction(pattern: $pattern, replacement: $replacement);
- }
-
- public function redact(array $payload): array
- {
- return $this->redactor->redact(payload: $payload);
- }
-}
diff --git a/src/Redactions/Rules/RemovedFieldsRedaction.php b/src/Redactions/Rules/RemovedFieldsRedaction.php
deleted file mode 100644
index 404781e..0000000
--- a/src/Redactions/Rules/RemovedFieldsRedaction.php
+++ /dev/null
@@ -1,41 +0,0 @@
-Preferred over a mask when the field carries no diagnostic value at all, such as a stack trace
- * or a raw payment code. Nothing about the original value reaches the log, not even its presence.
- */
-final readonly class RemovedFieldsRedaction implements Redaction
-{
- private Redaction $redactor;
-
- private function __construct(array $fields)
- {
- $this->redactor = new FieldRemover(fields: new FieldMatcher(fields: $fields));
- }
-
- /**
- * Creates a RemovedFieldsRedaction from the fields to drop.
- *
- * @param string[] $fields The field names removed from the payload, wildcards accepted.
- * @return RemovedFieldsRedaction The created instance.
- */
- public static function from(array $fields): RemovedFieldsRedaction
- {
- return new RemovedFieldsRedaction(fields: $fields);
- }
-
- public function redact(array $payload): array
- {
- return $this->redactor->redact(payload: $payload);
- }
-}
diff --git a/src/Redactions/Rules/ScopedRedaction.php b/src/Redactions/Rules/ScopedRedaction.php
deleted file mode 100644
index bc98b11..0000000
--- a/src/Redactions/Rules/ScopedRedaction.php
+++ /dev/null
@@ -1,48 +0,0 @@
-Field names repeat across a payload with different meanings. A value under
- * document is an identity document, a value under metadata
- * is an operational marker. Scoping a redaction to its parent keeps the first masked and the second
- * readable.
- */
-final readonly class ScopedRedaction implements Redaction
-{
- private Redaction $redactor;
-
- private function __construct(string $parent, Redaction $redaction)
- {
- $this->redactor = new ScopedRedactor(
- scope: new FieldMatcher(fields: [$parent]),
- redaction: $redaction
- );
- }
-
- /**
- * Creates a ScopedRedaction applying the given redaction only under the given parent field.
- *
- * @param string $parent The field name whose sub payloads the redaction is restricted to,
- * wildcards accepted.
- * @param Redaction $redaction The redaction applied within that scope.
- * @return ScopedRedaction The created instance.
- */
- public static function under(string $parent, Redaction $redaction): ScopedRedaction
- {
- return new ScopedRedaction(parent: $parent, redaction: $redaction);
- }
-
- public function redact(array $payload): array
- {
- return $this->redactor->redact(payload: $payload);
- }
-}
diff --git a/src/Redactions/Rules/VisibleEdgesRedaction.php b/src/Redactions/Rules/VisibleEdgesRedaction.php
deleted file mode 100644
index 804f521..0000000
--- a/src/Redactions/Rules/VisibleEdgesRedaction.php
+++ /dev/null
@@ -1,66 +0,0 @@
-The general primitive behind the field-specific strategies. Use it whenever a value must keep
- * part of its head, part of its tail, or both, and the domain strategies do not fit.
- */
-final readonly class VisibleEdgesRedaction implements Redaction
-{
- private Redaction $redactor;
-
- private function __construct(Mask $mask, array $fields, int $visiblePrefixLength, int $visibleSuffixLength)
- {
- $edges = new VisibleEdges(
- mask: $mask,
- prefixLength: $visiblePrefixLength,
- suffixLength: $visibleSuffixLength
- );
-
- $this->redactor = new Redactor(
- fields: new FieldMatcher(fields: $fields),
- maskingFunction: $edges->applyTo(...)
- );
- }
-
- /**
- * Creates a VisibleEdgesRedaction from the mask, the fields to redact, and the visible edges.
- *
- * @param Mask $mask The strategy rendering the hidden portion of each value.
- * @param string[] $fields The field names whose values are masked, wildcards accepted.
- * @param int $visiblePrefixLength The number of leading characters left visible.
- * @param int $visibleSuffixLength The number of trailing characters left visible.
- * @return VisibleEdgesRedaction The created instance.
- * @throws NegativeVisibleLength If either visible length is negative.
- */
- public static function from(
- Mask $mask,
- array $fields,
- int $visiblePrefixLength = 0,
- int $visibleSuffixLength = 0
- ): VisibleEdgesRedaction {
- return new VisibleEdgesRedaction(
- mask: $mask,
- fields: $fields,
- visiblePrefixLength: $visiblePrefixLength,
- visibleSuffixLength: $visibleSuffixLength
- );
- }
-
- public function redact(array $payload): array
- {
- return $this->redactor->redact(payload: $payload);
- }
-}
diff --git a/src/Redactions/Rules/WordwiseRedaction.php b/src/Redactions/Rules/WordwiseRedaction.php
deleted file mode 100644
index 5d76c13..0000000
--- a/src/Redactions/Rules/WordwiseRedaction.php
+++ /dev/null
@@ -1,70 +0,0 @@
-Suited to values made of several parts, such as a full name, where masking the value as a
- * single run collapses it into an unreadable line. Words are separated by whitespace and rejoined
- * with a single space.
- */
-final readonly class WordwiseRedaction implements Redaction
-{
- private Redaction $redactor;
-
- private function __construct(Mask $mask, array $fields, int $visiblePrefixLength, int $visibleSuffixLength)
- {
- $words = new VisibleWords(
- edges: new VisibleEdges(
- mask: $mask,
- prefixLength: $visiblePrefixLength,
- suffixLength: $visibleSuffixLength
- )
- );
-
- $this->redactor = new Redactor(
- fields: new FieldMatcher(fields: $fields),
- maskingFunction: $words->applyTo(...)
- );
- }
-
- /**
- * Creates a WordwiseRedaction from the mask, the fields to redact, and the visible edges.
- *
- * @param Mask $mask The strategy rendering the hidden portion of each word.
- * @param string[] $fields The field names whose values are masked, wildcards accepted.
- * @param int $visiblePrefixLength The number of leading characters of each word left visible.
- * @param int $visibleSuffixLength The number of trailing characters of each word left visible.
- * @return WordwiseRedaction The created instance.
- * @throws NegativeVisibleLength If either visible length is negative.
- */
- public static function from(
- Mask $mask,
- array $fields,
- int $visiblePrefixLength = 0,
- int $visibleSuffixLength = 0
- ): WordwiseRedaction {
- return new WordwiseRedaction(
- mask: $mask,
- fields: $fields,
- visiblePrefixLength: $visiblePrefixLength,
- visibleSuffixLength: $visibleSuffixLength
- );
- }
-
- public function redact(array $payload): array
- {
- return $this->redactor->redact(payload: $payload);
- }
-}
diff --git a/src/Redactions/SecretRedaction.php b/src/Redactions/SecretRedaction.php
new file mode 100644
index 0000000..333084e
--- /dev/null
+++ b/src/Redactions/SecretRedaction.php
@@ -0,0 +1,61 @@
+A secret carries no operational meaning once logged, and its length is itself a clue, so the
+ * mask is fixed. The default field list follows the naming conventions credentials travel under
+ * rather than a fixed set of names, which is what covers the field nobody remembered to declare.
+ */
+final readonly class SecretRedaction implements Redaction
+{
+ private const int DEFAULT_FIXED_MASK_LENGTH = 8;
+
+ private const array DEFAULT_FIELDS = [
+ '*token*',
+ '*secret*',
+ '*api_key*',
+ '*password*',
+ 'credentials',
+ 'authorization',
+ '*private_key*'
+ ];
+
+ private Redaction $redactor;
+
+ private function __construct(array $fields, int $fixedMaskLength)
+ {
+ $this->redactor = GenericRedaction::masking(mask: Mask::fixed(length: $fixedMaskLength), fields: $fields);
+ }
+
+ /**
+ * Creates a SecretRedaction from the fields to mask and the length of the fixed mask.
+ *
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
+ * @param int $fixedMaskLength The number of mask characters emitted for every value.
+ * @return SecretRedaction The created instance.
+ */
+ public static function from(array $fields, int $fixedMaskLength = self::DEFAULT_FIXED_MASK_LENGTH): SecretRedaction
+ {
+ return new SecretRedaction(fields: $fields, fixedMaskLength: $fixedMaskLength);
+ }
+
+ /**
+ * Builds a SecretRedaction covering the field name patterns credentials commonly travel under.
+ *
+ * @return SecretRedaction The created instance.
+ */
+ public static function default(): SecretRedaction
+ {
+ return SecretRedaction::from(fields: self::DEFAULT_FIELDS);
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/Visibility.php b/src/Redactions/Visibility.php
new file mode 100644
index 0000000..f25b8a8
--- /dev/null
+++ b/src/Redactions/Visibility.php
@@ -0,0 +1,109 @@
+The sibling decision of {@see Mask}, which renders the hidden portion. This one decides which
+ * portion is hidden at all: nothing survives, the edges of the value survive, the edges of every
+ * word survive, or everything before the at sign of an address is treated as the value.
+ */
+final readonly class Visibility
+{
+ private function __construct(
+ private VisibleShape $shape,
+ private int $prefixLength,
+ private int $suffixLength
+ ) {
+ if (min($prefixLength, $suffixLength) < 0) {
+ $template = 'Visible length cannot be negative, got prefix %d and suffix %d.';
+
+ throw new NegativeVisibleLength(message: sprintf($template, $prefixLength, $suffixLength));
+ }
+ }
+
+ /**
+ * Creates a Visibility where nothing of the value survives.
+ *
+ * @return Visibility The created instance.
+ */
+ public static function none(): Visibility
+ {
+ return new Visibility(shape: VisibleShape::EDGES, prefixLength: 0, suffixLength: 0);
+ }
+
+ /**
+ * Creates a Visibility keeping a window at the head, at the tail, or at both ends of the value.
+ *
+ * @param int $prefixLength The number of leading characters left visible.
+ * @param int $suffixLength The number of trailing characters left visible.
+ * @return Visibility The created instance.
+ * @throws NegativeVisibleLength If either length is negative.
+ */
+ public static function edges(int $prefixLength = 0, int $suffixLength = 0): Visibility
+ {
+ return new Visibility(
+ shape: VisibleShape::EDGES,
+ prefixLength: $prefixLength,
+ suffixLength: $suffixLength
+ );
+ }
+
+ /**
+ * Creates a Visibility keeping the same window on every word of the value.
+ *
+ * The value is split on whitespace and each word is masked on its own, so a multi word label
+ * keeps its shape instead of collapsing into a single run of mask characters.
+ *
+ * @param int $prefixLength The number of leading characters left visible in each word.
+ * @param int $suffixLength The number of trailing characters left visible in each word.
+ * @return Visibility The created instance.
+ * @throws NegativeVisibleLength If either length is negative.
+ */
+ public static function words(int $prefixLength = 0, int $suffixLength = 0): Visibility
+ {
+ return new Visibility(
+ shape: VisibleShape::WORDS,
+ prefixLength: $prefixLength,
+ suffixLength: $suffixLength
+ );
+ }
+
+ /**
+ * Creates a Visibility masking only what comes before the at sign, keeping the domain intact.
+ *
+ * A value with no at sign is hidden whole, so a malformed address never leaks by falling
+ * outside the rule.
+ *
+ * @param int $prefixLength The number of leading characters of the local part left visible.
+ * @return Visibility The created instance.
+ * @throws NegativeVisibleLength If the length is negative.
+ */
+ public static function localPart(int $prefixLength): Visibility
+ {
+ return new Visibility(shape: VisibleShape::LOCAL_PART, prefixLength: $prefixLength, suffixLength: 0);
+ }
+
+ /**
+ * Applies the mask to the portion of the value this visibility hides.
+ *
+ * @param Mask $mask The strategy rendering the hidden portion.
+ * @param string $value The original value.
+ * @return string The value with its hidden portion masked.
+ */
+ public function applyTo(Mask $mask, string $value): string
+ {
+ return $this->shape->applyTo(
+ mask: $mask,
+ value: $value,
+ prefixLength: $this->prefixLength,
+ suffixLength: $this->suffixLength
+ );
+ }
+}
diff --git a/src/StreamLogger.php b/src/StreamLogger.php
new file mode 100644
index 0000000..b355891
--- /dev/null
+++ b/src/StreamLogger.php
@@ -0,0 +1,97 @@
+template === ''
+ ? LogFormatter::fromComponent(component: $builder->component)
+ : LogFormatter::fromTemplate(template: $builder->template, component: $builder->component);
+
+ return new StreamLogger(
+ stream: LogStream::from(resource: $builder->stream),
+ formatter: $formatter,
+ redactions: Redactions::createFrom(elements: $builder->redactions),
+ correlation: $builder->correlation,
+ minimumLevel: $builder->minimumLevel
+ );
+ }
+
+ /**
+ * Creates a StreamLoggerBuilder.
+ *
+ * @return StreamLoggerBuilder A new builder for configuring a StreamLogger.
+ */
+ public static function builder(): StreamLoggerBuilder
+ {
+ return StreamLoggerBuilder::default();
+ }
+
+ /**
+ * Writes a log entry to the stream when its severity reaches the configured minimum level.
+ *
+ * @param mixed $level The severity, as received by the PSR-3 contract.
+ * @param string|Stringable $message The message key identifying the event.
+ * @param array $context The context data carried by the entry.
+ * @throws UnknownLogLevel If the level is outside the supported PSR-3 set.
+ */
+ public function log(mixed $level, string|Stringable $message, array $context = []): void
+ {
+ $logLevel = LogLevel::fromPsrLevel(level: $level);
+
+ if (!$logLevel->isAtLeast(threshold: $this->minimumLevel)) {
+ return;
+ }
+
+ $formatted = $this->formatter->format(
+ key: (string)$message,
+ level: $logLevel,
+ payload: $this->redactions->applyTo(payload: $context),
+ correlation: $this->correlation
+ );
+
+ $this->stream->write(content: $formatted);
+ }
+
+ public function withCorrelation(Correlation $correlation): StreamLogger
+ {
+ return new StreamLogger(
+ stream: $this->stream,
+ formatter: $this->formatter,
+ redactions: $this->redactions,
+ correlation: $correlation,
+ minimumLevel: $this->minimumLevel
+ );
+ }
+}
diff --git a/src/StreamLoggerBuilder.php b/src/StreamLoggerBuilder.php
new file mode 100644
index 0000000..52d4b6c
--- /dev/null
+++ b/src/StreamLoggerBuilder.php
@@ -0,0 +1,162 @@
+Immutable, so every with returns a copy and the builder it came from still
+ * describes what it described before.
+ */
+final readonly class StreamLoggerBuilder
+{
+ private function __construct(
+ public mixed $stream,
+ public string $template,
+ public string $component,
+ public array $redactions,
+ public ?Correlation $correlation,
+ public LogLevel $minimumLevel
+ ) {
+ }
+
+ /**
+ * Creates a StreamLoggerBuilder with the baseline description.
+ *
+ * @return StreamLoggerBuilder A builder writing to standard error, with no redaction and no threshold.
+ */
+ public static function default(): StreamLoggerBuilder
+ {
+ return new StreamLoggerBuilder(
+ stream: null,
+ template: '',
+ component: '',
+ redactions: [],
+ correlation: null,
+ minimumLevel: LogLevel::DEBUG
+ );
+ }
+
+ /**
+ * Builds a StreamLogger from what the builder describes.
+ *
+ * @return StreamLogger The configured logger instance.
+ */
+ public function build(): StreamLogger
+ {
+ return StreamLogger::from(builder: $this);
+ }
+
+ /**
+ * Returns a copy of the builder with the stream replaced.
+ *
+ * @param mixed $stream The stream log entries are written to.
+ * @return StreamLoggerBuilder A copy of the builder with the stream set.
+ */
+ public function withStream(mixed $stream): StreamLoggerBuilder
+ {
+ return new StreamLoggerBuilder(
+ stream: $stream,
+ template: $this->template,
+ component: $this->component,
+ redactions: $this->redactions,
+ correlation: $this->correlation,
+ minimumLevel: $this->minimumLevel
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the format template replaced.
+ *
+ * @param string $template The format template applied to every entry.
+ * @return StreamLoggerBuilder A copy of the builder with the template set.
+ */
+ public function withTemplate(string $template): StreamLoggerBuilder
+ {
+ return new StreamLoggerBuilder(
+ stream: $this->stream,
+ template: $template,
+ component: $this->component,
+ redactions: $this->redactions,
+ correlation: $this->correlation,
+ minimumLevel: $this->minimumLevel
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the component replaced.
+ *
+ * @param string $component The component name identifying the log source.
+ * @return StreamLoggerBuilder A copy of the builder with the component set.
+ */
+ public function withComponent(string $component): StreamLoggerBuilder
+ {
+ return new StreamLoggerBuilder(
+ stream: $this->stream,
+ template: $this->template,
+ component: $component,
+ redactions: $this->redactions,
+ correlation: $this->correlation,
+ minimumLevel: $this->minimumLevel
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the redactions added to the ones already described.
+ *
+ * @param Redaction ...$redactions The redaction strategies applied before writing.
+ * @return StreamLoggerBuilder A copy of the builder carrying the previous and the new redactions.
+ */
+ public function withRedactions(Redaction ...$redactions): StreamLoggerBuilder
+ {
+ return new StreamLoggerBuilder(
+ stream: $this->stream,
+ template: $this->template,
+ component: $this->component,
+ redactions: array_merge($this->redactions, $redactions),
+ correlation: $this->correlation,
+ minimumLevel: $this->minimumLevel
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the correlation replaced.
+ *
+ * @param Correlation $correlation The correlation shared across log entries.
+ * @return StreamLoggerBuilder A copy of the builder with the correlation set.
+ */
+ public function withCorrelation(Correlation $correlation): StreamLoggerBuilder
+ {
+ return new StreamLoggerBuilder(
+ stream: $this->stream,
+ template: $this->template,
+ component: $this->component,
+ redactions: $this->redactions,
+ correlation: $correlation,
+ minimumLevel: $this->minimumLevel
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the minimum level replaced.
+ *
+ * @param LogLevel $minimumLevel The lowest severity that is written.
+ * @return StreamLoggerBuilder A copy of the builder with the minimum level set.
+ */
+ public function withMinimumLevel(LogLevel $minimumLevel): StreamLoggerBuilder
+ {
+ return new StreamLoggerBuilder(
+ stream: $this->stream,
+ template: $this->template,
+ component: $this->component,
+ redactions: $this->redactions,
+ correlation: $this->correlation,
+ minimumLevel: $minimumLevel
+ );
+ }
+}
diff --git a/src/StructuredLogger.php b/src/StructuredLogger.php
deleted file mode 100644
index 670793d..0000000
--- a/src/StructuredLogger.php
+++ /dev/null
@@ -1,108 +0,0 @@
- $context The context data carried by the entry.
- * @throws UnknownLogLevel If the level is outside the supported PSR-3 set.
- */
- public function log(mixed $level, string|Stringable $message, array $context = []): void
- {
- $logLevel = LogLevel::fromPsrLevel(level: $level);
-
- if (!$logLevel->isAtLeast(threshold: $this->minimumLevel)) {
- return;
- }
-
- $formatted = $this->formatter->format(
- key: (string)$message,
- level: $logLevel,
- payload: $this->redactions->applyTo(payload: $context),
- context: $this->context
- );
-
- $this->stream->write(content: $formatted);
- }
-
- public function withContext(LogContext $context): StructuredLogger
- {
- return new StructuredLogger(
- stream: $this->stream,
- context: $context,
- formatter: $this->formatter,
- redactions: $this->redactions,
- minimumLevel: $this->minimumLevel
- );
- }
-}
diff --git a/src/StructuredLoggerBuilder.php b/src/StructuredLoggerBuilder.php
deleted file mode 100644
index b3e3a7d..0000000
--- a/src/StructuredLoggerBuilder.php
+++ /dev/null
@@ -1,148 +0,0 @@
-stream,
- $this->context,
- $this->template,
- $this->component,
- $this->minimumLevel,
- ...$this->redactions
- );
- }
-
- /**
- * Returns a copy of the builder with the stream replaced.
- *
- * @param mixed $stream The stream the logger writes to.
- * @return StructuredLoggerBuilder A copy of the builder with the stream set.
- */
- public function withStream(mixed $stream): StructuredLoggerBuilder
- {
- return new StructuredLoggerBuilder(
- stream: $stream,
- context: $this->context,
- template: $this->template,
- component: $this->component,
- redactions: $this->redactions,
- minimumLevel: $this->minimumLevel
- );
- }
-
- /**
- * Returns a copy of the builder with the context replaced.
- *
- * @param LogContext $context The context shared across log entries.
- * @return StructuredLoggerBuilder A copy of the builder with the context set.
- */
- public function withContext(LogContext $context): StructuredLoggerBuilder
- {
- return new StructuredLoggerBuilder(
- stream: $this->stream,
- context: $context,
- template: $this->template,
- component: $this->component,
- redactions: $this->redactions,
- minimumLevel: $this->minimumLevel
- );
- }
-
- /**
- * Returns a copy of the builder with the template replaced.
- *
- * @param string $template The format template for rendering entries.
- * @return StructuredLoggerBuilder A copy of the builder with the template set.
- */
- public function withTemplate(string $template): StructuredLoggerBuilder
- {
- return new StructuredLoggerBuilder(
- stream: $this->stream,
- context: $this->context,
- template: $template,
- component: $this->component,
- redactions: $this->redactions,
- minimumLevel: $this->minimumLevel
- );
- }
-
- /**
- * Returns a copy of the builder with the component replaced.
- *
- * @param string $component The component name identifying the log source.
- * @return StructuredLoggerBuilder A copy of the builder with the component set.
- */
- public function withComponent(string $component): StructuredLoggerBuilder
- {
- return new StructuredLoggerBuilder(
- stream: $this->stream,
- context: $this->context,
- template: $this->template,
- component: $component,
- redactions: $this->redactions,
- minimumLevel: $this->minimumLevel
- );
- }
-
- /**
- * Returns a copy of the builder with the given redactions appended.
- *
- * @param Redaction ...$redactions The redaction strategies to apply to log data.
- * @return StructuredLoggerBuilder A copy of the builder with the redactions appended.
- */
- public function withRedactions(Redaction ...$redactions): StructuredLoggerBuilder
- {
- return new StructuredLoggerBuilder(
- stream: $this->stream,
- context: $this->context,
- template: $this->template,
- component: $this->component,
- redactions: array_merge($this->redactions, $redactions),
- minimumLevel: $this->minimumLevel
- );
- }
-
- /**
- * Returns a copy of the builder with the minimum level replaced.
- *
- * @param LogLevel $minimumLevel The lowest severity that is written, quieter entries are discarded.
- * @return StructuredLoggerBuilder A copy of the builder with the minimum level set.
- */
- public function withMinimumLevel(LogLevel $minimumLevel): StructuredLoggerBuilder
- {
- return new StructuredLoggerBuilder(
- stream: $this->stream,
- context: $this->context,
- template: $this->template,
- component: $this->component,
- redactions: $this->redactions,
- minimumLevel: $minimumLevel
- );
- }
-}
diff --git a/src/TelemetryLogger.php b/src/TelemetryLogger.php
new file mode 100644
index 0000000..7bca7d1
--- /dev/null
+++ b/src/TelemetryLogger.php
@@ -0,0 +1,103 @@
+Two kinds of record travel here, and they never share a line. An entry is narrative and carries
+ * a severity; a metric is a measurement and carries none, so a threshold raised to quiet the logs
+ * does not stop a series. What the logger knows about itself rides on both, which is what lets a
+ * series be traced back to the surrounding entries.
+ * Build a {@see StreamLogger} instead when nothing is measured. This type exists so that
+ * having metrics is a choice made once, where the logger is assembled, and not an argument repeated
+ * at every call.
+ */
+final readonly class TelemetryLogger implements Logger
+{
+ use LoggerTrait;
+
+ private function __construct(
+ private StreamLogger $logger,
+ private MetricWriter $writer,
+ private ?Correlation $correlation
+ ) {
+ }
+
+ /**
+ * Creates a TelemetryLogger from what its builder describes.
+ *
+ * @param TelemetryLoggerBuilder $builder The metric format, plus everything the log side takes.
+ * @return TelemetryLogger The created logger instance.
+ */
+ public static function from(TelemetryLoggerBuilder $builder): TelemetryLogger
+ {
+ return new TelemetryLogger(
+ logger: $builder->logger->build(),
+ writer: new MetricWriter(
+ format: $builder->format,
+ stream: LogStream::from(resource: $builder->logger->stream),
+ component: $builder->logger->component,
+ redactions: Redactions::createFrom(elements: $builder->logger->redactions)
+ ),
+ correlation: $builder->logger->correlation
+ );
+ }
+
+ /**
+ * Creates a TelemetryLoggerBuilder around the format that renders every metric it writes.
+ *
+ * The format is taken here rather than at build time because a logger of this kind without one
+ * has nothing to measure with, and an object that cannot be valid should not be reachable.
+ *
+ * @param MetricFormat $format The format rendering a metric for the backend that reads it.
+ * @return TelemetryLoggerBuilder A new builder for configuring a TelemetryLogger.
+ */
+ public static function builder(MetricFormat $format): TelemetryLoggerBuilder
+ {
+ return TelemetryLoggerBuilder::from(format: $format);
+ }
+
+ /**
+ * Writes a log entry when its severity reaches the configured minimum level.
+ *
+ * @param mixed $level The severity, as received by the PSR-3 contract.
+ * @param string|Stringable $message The message key identifying the event.
+ * @param array $context The context data carried by the entry.
+ * @throws UnknownLogLevel If the level is outside the supported PSR-3 set.
+ */
+ public function log(mixed $level, string|Stringable $message, array $context = []): void
+ {
+ $this->logger->log($level, $message, $context);
+ }
+
+ /**
+ * Writes a metric, in the shape the configured format renders it.
+ *
+ * @param Metric $metric The metric to write.
+ */
+ public function metric(Metric $metric): void
+ {
+ $this->writer->write(metric: $metric, correlation: $this->correlation);
+ }
+
+ public function withCorrelation(Correlation $correlation): TelemetryLogger
+ {
+ return new TelemetryLogger(
+ logger: $this->logger->withCorrelation(correlation: $correlation),
+ writer: $this->writer,
+ correlation: $correlation
+ );
+ }
+}
diff --git a/src/TelemetryLoggerBuilder.php b/src/TelemetryLoggerBuilder.php
new file mode 100644
index 0000000..4ee8dc3
--- /dev/null
+++ b/src/TelemetryLoggerBuilder.php
@@ -0,0 +1,126 @@
+What describes the log side is a {@see StreamLoggerBuilder}, so the two loggers are configured
+ * by the same vocabulary and the metric format is the only thing this one adds.
+ */
+final readonly class TelemetryLoggerBuilder
+{
+ private function __construct(public MetricFormat $format, public StreamLoggerBuilder $logger)
+ {
+ }
+
+ /**
+ * Creates a TelemetryLoggerBuilder around the format that renders every metric it writes.
+ *
+ * @param MetricFormat $format The format rendering a metric for the backend that reads it.
+ * @return TelemetryLoggerBuilder A builder writing to standard error, with no redaction and no threshold.
+ */
+ public static function from(MetricFormat $format): TelemetryLoggerBuilder
+ {
+ return new TelemetryLoggerBuilder(format: $format, logger: StreamLoggerBuilder::default());
+ }
+
+ /**
+ * Builds a TelemetryLogger from what the builder describes.
+ *
+ * @return TelemetryLogger The configured logger instance.
+ */
+ public function build(): TelemetryLogger
+ {
+ return TelemetryLogger::from(builder: $this);
+ }
+
+ /**
+ * Returns a copy of the builder with the stream replaced.
+ *
+ * @param mixed $stream The stream both records are written to.
+ * @return TelemetryLoggerBuilder A copy of the builder with the stream set.
+ */
+ public function withStream(mixed $stream): TelemetryLoggerBuilder
+ {
+ return new TelemetryLoggerBuilder(
+ format: $this->format,
+ logger: $this->logger->withStream(stream: $stream)
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the format template replaced.
+ *
+ * @param string $template The format template applied to every entry.
+ * @return TelemetryLoggerBuilder A copy of the builder with the template set.
+ */
+ public function withTemplate(string $template): TelemetryLoggerBuilder
+ {
+ return new TelemetryLoggerBuilder(
+ format: $this->format,
+ logger: $this->logger->withTemplate(template: $template)
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the component replaced.
+ *
+ * @param string $component The component name identifying the source of both records.
+ * @return TelemetryLoggerBuilder A copy of the builder with the component set.
+ */
+ public function withComponent(string $component): TelemetryLoggerBuilder
+ {
+ return new TelemetryLoggerBuilder(
+ format: $this->format,
+ logger: $this->logger->withComponent(component: $component)
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the redactions added to the ones already described.
+ *
+ * @param Redaction ...$redactions The redaction strategies applied before writing.
+ * @return TelemetryLoggerBuilder A copy of the builder carrying the previous and the new redactions.
+ */
+ public function withRedactions(Redaction ...$redactions): TelemetryLoggerBuilder
+ {
+ return new TelemetryLoggerBuilder(
+ format: $this->format,
+ logger: $this->logger->withRedactions(...$redactions)
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the correlation replaced.
+ *
+ * @param Correlation $correlation The correlation shared across log entries and metrics.
+ * @return TelemetryLoggerBuilder A copy of the builder with the correlation set.
+ */
+ public function withCorrelation(Correlation $correlation): TelemetryLoggerBuilder
+ {
+ return new TelemetryLoggerBuilder(
+ format: $this->format,
+ logger: $this->logger->withCorrelation(correlation: $correlation)
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the minimum level replaced.
+ *
+ * @param LogLevel $minimumLevel The lowest severity that is written.
+ * @return TelemetryLoggerBuilder A copy of the builder with the minimum level set.
+ */
+ public function withMinimumLevel(LogLevel $minimumLevel): TelemetryLoggerBuilder
+ {
+ return new TelemetryLoggerBuilder(
+ format: $this->format,
+ logger: $this->logger->withMinimumLevel(minimumLevel: $minimumLevel)
+ );
+ }
+}
diff --git a/tests/Models/EmbeddedMetricPayload.php b/tests/Models/EmbeddedMetricPayload.php
new file mode 100644
index 0000000..6e20919
--- /dev/null
+++ b/tests/Models/EmbeddedMetricPayload.php
@@ -0,0 +1,54 @@
+declaration()['Metrics'][0]['Unit'];
+ }
+
+ public function metrics(): mixed
+ {
+ return $this->declaration()['Metrics'];
+ }
+
+ public function timestamp(): mixed
+ {
+ return $this->envelope()['Timestamp'];
+ }
+
+ public function namespaced(): mixed
+ {
+ return $this->declaration()['Namespace'];
+ }
+
+ public function dimensions(): mixed
+ {
+ return $this->declaration()['Dimensions'];
+ }
+
+ private function envelope(): array
+ {
+ return (array)$this->payload['_aws'];
+ }
+
+ private function declaration(): array
+ {
+ $declarations = (array)$this->envelope()['CloudWatchMetrics'];
+
+ return (array)$declarations[0];
+ }
+}
diff --git a/tests/RedactionsTest.php b/tests/RedactionsTest.php
deleted file mode 100644
index b79c6ab..0000000
--- a/tests/RedactionsTest.php
+++ /dev/null
@@ -1,497 +0,0 @@
-logStream = InMemoryStream::create();
- }
-
- protected function tearDown(): void
- {
- $this->logStream->close();
- }
-
- public function testRedactWhenVisibleEdgesThenKeepsBothEnds(): void
- {
- /** @Given a structured logger keeping the head and the tail of the phone */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'contact-service')
- ->withRedactions(
- VisibleEdgesRedaction::from(
- mask: Mask::proportional(),
- fields: ['phone'],
- visiblePrefixLength: 5,
- visibleSuffixLength: 4
- )
- )
- ->build();
-
- /** @When logging with a phone field */
- $logger->info(message: 'contact.updated', context: ['phone' => '+5511999887766']);
-
- /** @Then both edges of the value stay visible and the middle is masked */
- self::assertStringContainsString('data={"phone":"+5511*****7766"}', $this->logStream->contents());
- }
-
- public function testRedactWhenWordwiseThenMasksEachWordApart(): void
- {
- /** @Given a structured logger masking each word of a name with a fixed-width mask */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'user-service')
- ->withRedactions(
- WordwiseRedaction::from(
- mask: Mask::fixed(length: 3),
- fields: ['name'],
- visiblePrefixLength: 2
- )
- )
- ->build();
-
- /** @When logging with a name made of several words */
- $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']);
-
- /** @Then every word keeps its own visible prefix */
- self::assertStringContainsString('data={"name":"Gu*** Fr***"}', $this->logStream->contents());
- }
-
- public function testRedactWhenFullMaskThenHidesTheValueLength(): void
- {
- /** @Given a structured logger masking two fields of different lengths with a fixed mask */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'audit-service')
- ->withRedactions(
- FullMaskRedaction::from(mask: Mask::fixed(length: 4), fields: ['user_agent', 'session_id'])
- )
- ->build();
-
- /** @When logging with both fields and an unrelated one */
- $logger->info(message: 'request.received', context: [
- 'user_agent' => 'Mozilla/5.0',
- 'session_id' => 'abc',
- 'route' => '/v1/users'
- ]);
-
- /** @Then both masks have the same width regardless of the original length */
- $output = $this->logStream->contents();
-
- self::assertStringContainsString('"user_agent":"****"', $output);
- self::assertStringContainsString('"session_id":"****"', $output);
- self::assertStringContainsString('"route":"/v1/users"', $output);
- }
-
- public function testRedactWhenValueIsNumericThenMasksItAsText(): void
- {
- /** @Given a structured logger with document redaction */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'kyc-service')
- ->withRedactions(DocumentRedaction::default())
- ->build();
-
- /** @When logging with a document decoded from JSON as an integer */
- $logger->info(message: 'kyc.verified', context: ['document' => 12345678900, 'amount' => 100]);
-
- /** @Then the numeric document is masked as text and the unrelated number is preserved */
- self::assertStringContainsString('data={"document":"********900","amount":100}', $this->logStream->contents());
- }
-
- public function testRedactWhenListOfValuesThenMasksEachElement(): void
- {
- /** @Given a structured logger with phone redaction */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'notification-service')
- ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 4))
- ->build();
-
- /** @When logging with a sensitive field holding a list of values */
- $logger->info(message: 'sms.queued', context: ['phone' => ['+5511999887766', '+5521988776655']]);
-
- /** @Then every element of the list is masked */
- $output = $this->logStream->contents();
-
- self::assertStringContainsString('data={"phone":["**********7766","**********6655"]}', $output);
- self::assertStringNotContainsString('+5511999887766', $output);
- }
-
- public function testRedactWhenValueIsNullThenLeavesItUntouched(): void
- {
- /** @Given a structured logger with document redaction */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'kyc-service')
- ->withRedactions(DocumentRedaction::default())
- ->build();
-
- /** @When logging with a sensitive field holding no value */
- $logger->info(message: 'kyc.skipped', context: ['document' => null, 'status' => 'pending']);
-
- /** @Then the absent value is written as is */
- self::assertStringContainsString('data={"document":null,"status":"pending"}', $this->logStream->contents());
- }
-
- public function testRedactWhenWildcardFieldThenMasksEveryMatch(): void
- {
- /** @Given a structured logger with a name redaction targeting a field name pattern */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'messaging-service')
- ->withRedactions(NameRedaction::from(fields: ['*_name'], visiblePrefixLength: 2))
- ->build();
-
- /** @When logging with a matching field, a field that does not match, and a list */
- $logger->info(message: 'message.dispatched', context: [
- 'client_name' => 'João Silva',
- 'name' => 'Maria',
- 'tags' => ['first', 'retry']
- ]);
-
- /** @Then only the matching field is masked */
- $output = $this->logStream->contents();
-
- self::assertStringContainsString('"client_name":"Jo********"', $output);
- self::assertStringContainsString('"name":"Maria"', $output);
-
- /** @And the numeric keys of the list are matched against the pattern without failing */
- self::assertStringContainsString('"tags":["first","retry"]', $output);
- }
-
- public function testRedactWhenScopedThenMasksOnlyInsideTheScope(): void
- {
- /** @Given a structured logger masking the value field only under a document parent */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'payment-service')
- ->withRedactions(
- ScopedRedaction::under(
- parent: 'document',
- redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2)
- )
- )
- ->build();
-
- /** @When logging with the same field name inside and outside the scope */
- $logger->info(message: 'payment.created', context: [
- 'document' => ['type' => 'cpf', 'value' => '12345678901'],
- 'metadata' => ['value' => 'operational-marker'],
- 'charge' => ['document' => ['value' => '98765432100']]
- ]);
-
- /** @Then the value inside the scope is masked */
- $output = $this->logStream->contents();
-
- self::assertStringContainsString('"document":{"type":"cpf","value":"*********01"}', $output);
-
- /** @And the value outside the scope is preserved */
- self::assertStringContainsString('"metadata":{"value":"operational-marker"}', $output);
-
- /** @And the scope is honored at any depth */
- self::assertStringContainsString('"charge":{"document":{"value":"*********00"}}', $output);
- }
-
- public function testRedactWhenPatternMatchesTextThenMasksTheMatch(): void
- {
- /** @Given a structured logger masking eleven-digit runs found in any text */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'error-service')
- ->withRedactions(PatternRedaction::from(pattern: '/\d{11}/', replacement: '[REDACTED]'))
- ->build();
-
- /** @When logging an exception message and a URI carrying the document */
- $logger->error(message: 'request.failed', context: [
- 'message' => 'Document 12345678901 is invalid.',
- 'attempts' => 3,
- 'nested' => ['uri' => '/clients/12345678901']
- ]);
-
- /** @Then the match inside the free text is replaced */
- $output = $this->logStream->contents();
-
- self::assertStringContainsString('"message":"Document [REDACTED] is invalid."', $output);
-
- /** @And the match inside the nested URI is replaced */
- self::assertStringContainsString('"nested":{"uri":"/clients/[REDACTED]"}', $output);
-
- /** @And values that are not text are left alone */
- self::assertStringContainsString('"attempts":3', $output);
- }
-
- public function testRedactWhenAllowListThenMasksEveryFieldOutsideIt(): void
- {
- /** @Given a structured logger allowing only the city and the state under the address */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'payment-service')
- ->withRedactions(
- ScopedRedaction::under(
- parent: 'address',
- redaction: AllowedFieldsRedaction::from(mask: Mask::fixed(length: 3), fields: ['city', 'state'])
- )
- )
- ->build();
-
- /** @When logging with an address and a sibling field outside the scope */
- $logger->info(message: 'payment.created', context: [
- 'address' => ['city' => 'São Paulo', 'state' => 'BR-SP', 'street' => 'Rua Example'],
- 'name' => 'Maria'
- ]);
-
- /** @Then the allowed fields survive and everything else in the scope is masked */
- $output = $this->logStream->contents();
-
- self::assertStringContainsString('"city":"São Paulo","state":"BR-SP","street":"***"', $output);
-
- /** @And fields outside the scope are untouched */
- self::assertStringContainsString('"name":"Maria"', $output);
- }
-
- public function testRedactWhenScopeHoldsScalarThenLeavesItUntouched(): void
- {
- /** @Given a structured logger masking the value field only under a document parent */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'payment-service')
- ->withRedactions(
- ScopedRedaction::under(
- parent: 'document',
- redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2)
- )
- )
- ->build();
-
- /** @When logging with the scope parent holding a scalar instead of a sub payload */
- $logger->info(message: 'payment.created', context: ['document' => 'plain-value']);
-
- /** @Then the scalar is written as is */
- self::assertStringContainsString('data={"document":"plain-value"}', $this->logStream->contents());
- }
-
- public function testRedactWhenCommonSecretsThenMasksEverySecretField(): void
- {
- /** @Given a structured logger with the common secrets redaction */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'auth-service')
- ->withRedactions(FullMaskRedaction::commonSecrets())
- ->build();
-
- /** @When logging with one field per secret naming convention */
- $logger->info(message: 'auth.check', context: [
- 'user_id' => 'u-1',
- 'credentials' => 'basic',
- 'access_token' => 'at-1',
- 'authorization' => 'Bearer x',
- 'client_secret' => 'cs-1',
- 'stripe_api_key' => 'ak-1',
- 'rsa_private_key' => 'pk-1',
- 'current_password' => 'pw-1'
- ]);
-
- /** @Then every secret field is fully masked */
- $output = $this->logStream->contents();
-
- self::assertStringContainsString('"credentials":"********"', $output);
- self::assertStringContainsString('"access_token":"********"', $output);
- self::assertStringContainsString('"authorization":"********"', $output);
- self::assertStringContainsString('"client_secret":"********"', $output);
- self::assertStringContainsString('"stripe_api_key":"********"', $output);
- self::assertStringContainsString('"rsa_private_key":"********"', $output);
- self::assertStringContainsString('"current_password":"********"', $output);
-
- /** @And fields outside the convention are preserved */
- self::assertStringContainsString('"user_id":"u-1"', $output);
- }
-
- public function testRedactWhenFieldsRemovedThenDropsThemFromTheEntry(): void
- {
- /** @Given a structured logger dropping the trace and every token field */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'error-service')
- ->withRedactions(RemovedFieldsRedaction::from(fields: ['trace', '*_token']))
- ->build();
-
- /** @When logging with those fields at the root and nested */
- $logger->error(message: 'request.failed', context: [
- 'message' => 'boom',
- 'trace' => '#0 stack',
- 'nested' => ['access_token' => 'at-1', 'id' => '7']
- ]);
-
- /** @Then the dropped fields leave no trace in the entry */
- self::assertStringContainsString('data={"message":"boom","nested":{"id":"7"}}', $this->logStream->contents());
- }
-
- public function testRedactWhenSeparatorsKeptThenPreservesPunctuation(): void
- {
- /** @Given a structured logger masking a postal code while keeping its separators */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'payment-service')
- ->withRedactions(
- VisibleEdgesRedaction::from(
- mask: Mask::preservingSeparators(),
- fields: ['postal_code', 'reference', 'coordinates'],
- visiblePrefixLength: 3
- )
- )
- ->build();
-
- /** @When logging with a formatted postal code, an accented reference, and a coordinate */
- $logger->info(message: 'address.saved', context: [
- 'postal_code' => '01310-100',
- 'reference' => 'Rua Açaí, 42',
- 'coordinates' => 'S 23° 33'
- ]);
-
- /** @Then digits are hidden and the separator survives */
- $output = $this->logStream->contents();
-
- self::assertStringContainsString('"postal_code":"013**-***"', $output);
-
- /** @And accented letters are hidden as single characters */
- self::assertStringContainsString('"reference":"Rua ****, **"', $output);
-
- /** @And symbols outside the letter and digit categories survive */
- self::assertStringContainsString('"coordinates":"S 2*° **"', $output);
- }
-
- public function testRedactWhenMapOfValuesThenDescendsInsteadOfMasking(): void
- {
- /** @Given a structured logger with document redaction */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'kyc-service')
- ->withRedactions(DocumentRedaction::default())
- ->build();
-
- /** @When logging with a sensitive field holding a map instead of a value */
- $logger->info(message: 'kyc.verified', context: [
- 'document' => ['type' => 'cnpj', 'value' => '12345678000199', 'issued_at' => '2020-01-01']
- ]);
-
- /** @Then the map is descended into, so the fields that carry their own meaning survive */
- self::assertStringContainsString(
- 'data={"document":{"type":"cnpj","value":"12345678000199","issued_at":"2020-01-01"}}',
- $this->logStream->contents()
- );
- }
-
- public function testRedactWhenPatternIsInvalidThenThrowsInvalidPattern(): void
- {
- /** @Then an exception describing the rejected pattern is raised */
- $this->expectException(InvalidRedactionPattern::class);
-
- /** @And the message names the offending pattern */
- $this->expectExceptionMessage('Pattern is not a valid regular expression: /[unclosed/.');
-
- /** @When building a redaction from a pattern the engine cannot compile */
- PatternRedaction::from(pattern: '/[unclosed/', replacement: '[REDACTED]');
- }
-
- public function testRedactWhenPrefixIsNegativeThenThrowsNegativeLength(): void
- {
- /** @Then an exception describing the rejected configuration is raised */
- $this->expectException(NegativeVisibleLength::class);
-
- /** @And the message names both visible lengths */
- $this->expectExceptionMessage('Visible length cannot be negative, got prefix -1 and suffix 4.');
-
- /** @When building a redaction that leaves a negative number of leading characters visible */
- VisibleEdgesRedaction::from(
- mask: Mask::proportional(),
- fields: ['phone'],
- visiblePrefixLength: -1,
- visibleSuffixLength: 4
- );
- }
-
- public function testRedactWhenSuffixIsNegativeThenThrowsNegativeLength(): void
- {
- /** @Then an exception describing the rejected configuration is raised */
- $this->expectException(NegativeVisibleLength::class);
-
- /** @And the message names both visible lengths */
- $this->expectExceptionMessage('Visible length cannot be negative, got prefix 2 and suffix -1.');
-
- /** @When building a redaction that leaves a negative number of trailing characters visible */
- VisibleEdgesRedaction::from(
- mask: Mask::proportional(),
- fields: ['phone'],
- visiblePrefixLength: 2,
- visibleSuffixLength: -1
- );
- }
-
- public function testRedactWhenWordwiseHasNoPrefixThenMasksEveryWordHead(): void
- {
- /** @Given a structured logger masking each word down to its last character */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'user-service')
- ->withRedactions(
- WordwiseRedaction::from(
- mask: Mask::proportional(),
- fields: ['name'],
- visibleSuffixLength: 1
- )
- )
- ->build();
-
- /** @When logging with a name made of several words */
- $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']);
-
- /** @Then every word keeps only its trailing character */
- self::assertStringContainsString('data={"name":"******o ****e"}', $this->logStream->contents());
- }
-
- public function testRedactWhenAllowListAndNullValueThenLeavesItUntouched(): void
- {
- /** @Given a structured logger allowing only the city field */
- $logger = StructuredLogger::create()
- ->withStream(stream: $this->logStream->handle())
- ->withComponent(component: 'payment-service')
- ->withRedactions(AllowedFieldsRedaction::from(mask: Mask::fixed(length: 3), fields: ['city']))
- ->build();
-
- /** @When logging with an absent value and a nested allowed field */
- $logger->info(message: 'address.saved', context: [
- 'city' => 'São Paulo',
- 'street' => null,
- 'nested' => ['city' => 'Rio', 'label' => 'x']
- ]);
-
- /** @Then the absent value is written as is and the nested allow list still applies */
- $output = $this->logStream->contents();
-
- self::assertStringContainsString('"city":"São Paulo","street":null', $output);
- self::assertStringContainsString('"nested":{"city":"Rio","label":"***"}', $output);
- }
-}
diff --git a/tests/Unit/EmbeddedMetricFormatTest.php b/tests/Unit/EmbeddedMetricFormatTest.php
new file mode 100644
index 0000000..9fe8f91
--- /dev/null
+++ b/tests/Unit/EmbeddedMetricFormatTest.php
@@ -0,0 +1,217 @@
+withField(name: 'tenant_id', value: 'abc-123')
+ ->withField(name: 'attempt', value: 2);
+
+ /** @When rendering it */
+ $payload = EmbeddedMetricFormat::default()->format(metric: $metric);
+
+ /** @Then they are in the record and the dimension set stays empty */
+ self::assertSame('abc-123', $payload['tenant_id']);
+ self::assertSame(2, $payload['attempt']);
+ self::assertSame([[]], EmbeddedMetricPayload::from(payload: $payload)->dimensions());
+ }
+
+ public function testFormatWhenTimestampRenderedThenIsInMilliseconds(): void
+ {
+ /** @Given a counter */
+ $metric = Metric::of(name: 'AppointmentNoShow', namespace: 'Acme/Schedule');
+
+ /** @When rendering it */
+ $timestamp = EmbeddedMetricPayload::from(
+ payload: EmbeddedMetricFormat::default()->format(metric: $metric)
+ )->timestamp();
+
+ /** @Then the timestamp is the current instant expressed in milliseconds */
+ $secondsNow = time();
+ $lowerBound = (($secondsNow - 1) * 1000);
+ $upperBound = (($secondsNow + 2) * 1000);
+
+ self::assertIsInt($timestamp);
+ self::assertGreaterThanOrEqual($lowerBound, $timestamp);
+ self::assertLessThan($upperBound, $timestamp);
+ }
+
+ #[DataProvider('unitProvider')]
+
+ public function testFormatWhenUnitGivenThenSpellsItAsCloudWatchReadsIt(MetricUnit $unit, string $token): void
+ {
+ /** @Given a measurement in one of the units the neutral vocabulary carries */
+ $metric = Metric::of(name: 'OfferResponseTime', namespace: 'Acme/Waitlist')
+ ->withUnit(unit: $unit)
+ ->withValue(value: 12.5);
+
+ /** @When rendering it */
+ $payload = EmbeddedMetricFormat::default()->format(metric: $metric);
+
+ /** @Then the declaration carries the CloudWatch token, and the value keeps its precision */
+ self::assertSame(
+ [['Name' => 'OfferResponseTime', 'Unit' => $token]],
+ EmbeddedMetricPayload::from(payload: $payload)->metrics()
+ );
+ self::assertSame(12.5, $payload['OfferResponseTime']);
+ }
+
+ public function testFormatWhenNoNameIsRefusedThenAnyDimensionIsAccepted(): void
+ {
+ /** @Given a format that was told to refuse nothing */
+ $format = EmbeddedMetricFormat::default();
+
+ /** @And a counter broken down by a name another consumer might refuse */
+ $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist')
+ ->withDimension(name: 'tenant_id', value: 'abc-123');
+
+ /** @When rendering it */
+ $payload = $format->format(metric: $metric);
+
+ /** @Then nothing is refused, because which names are unbounded is not the library's to know */
+ self::assertSame([['tenant_id']], EmbeddedMetricPayload::from(payload: $payload)->dimensions());
+ }
+
+ public function testFormatWhenNoDimensionThenDeclaresTheEmptyDimensionSet(): void
+ {
+ /** @Given a counter with no dimension and no field */
+ $metric = Metric::of(name: 'OfferAccepted', namespace: 'Acme/Waitlist');
+
+ /** @When rendering it */
+ $payload = EmbeddedMetricFormat::default()->format(metric: $metric);
+
+ /** @Then the metric declares one empty dimension set and counts one */
+ $record = EmbeddedMetricPayload::from(payload: $payload);
+
+ self::assertSame('Acme/Waitlist', $record->namespaced());
+ self::assertSame([[]], $record->dimensions());
+ self::assertSame([['Name' => 'OfferAccepted', 'Unit' => 'Count']], $record->metrics());
+ self::assertSame(1, $payload['OfferAccepted']);
+ }
+
+ public function testFormatWhenDimensionDeclaredThenIsRepeatedAsAFieldOfTheRecord(): void
+ {
+ /** @Given a counter broken down by a bounded dimension */
+ $metric = Metric::of(name: 'SubscriptionRenewed', namespace: 'Acme/Subscription')
+ ->withDimension(name: 'subscription_type', value: 'saas');
+
+ /** @When rendering it */
+ $payload = EmbeddedMetricFormat::default()->format(metric: $metric);
+
+ /** @Then the dimension is declared and repeated at the root, which is what EMF resolves */
+ self::assertSame([['subscription_type']], EmbeddedMetricPayload::from(payload: $payload)->dimensions());
+ self::assertSame('saas', $payload['subscription_type']);
+ }
+
+ public function testFormatWhenAFieldIsNamedAfterTheEnvelopeThenTheEnvelopeSurvives(): void
+ {
+ /** @Given a counter carrying a field whose name collides with the envelope key */
+ $metric = Metric::of(name: 'OfferAccepted', namespace: 'Acme/Waitlist')
+ ->withField(name: '_aws', value: 'oops');
+
+ /** @When rendering it */
+ $payload = EmbeddedMetricFormat::default()->format(metric: $metric);
+
+ /** @Then the envelope wins, so the record is still a metric and not a plain log line */
+ self::assertSame('Acme/Waitlist', EmbeddedMetricPayload::from(payload: $payload)->namespaced());
+ }
+
+ public function testWithUnboundedDimensionsWhenCalledTwiceThenTheEarlierNameSurvives(): void
+ {
+ /** @Given a format told about one name and then, in another call, about a different one */
+ $format = EmbeddedMetricFormat::default()
+ ->withUnboundedDimensions('user_id')
+ ->withUnboundedDimensions('tenant_id');
+
+ /** @And a counter broken down by the name of the first call */
+ $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist')
+ ->withDimension(name: 'user_id', value: 'abc-123');
+
+ /** @Then the earlier list is added to, and not replaced by the later one */
+ $this->expectException(UnboundedDimension::class);
+
+ /** @When rendering it */
+ $format->format(metric: $metric);
+ }
+
+ public function testWithUnboundedDimensionsWhenDerivedThenTheOriginalStillAcceptsTheName(): void
+ {
+ /** @Given a format that refuses nothing */
+ $format = EmbeddedMetricFormat::default();
+
+ /** @And a copy of it that refuses a name */
+ $derived = $format->withUnboundedDimensions('tenant_id');
+
+ /** @And a counter broken down by that name */
+ $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist')
+ ->withDimension(name: 'tenant_id', value: 'abc-123');
+
+ /** @When rendering through the original */
+ $payload = $format->format(metric: $metric);
+
+ /** @Then the copy is another instance and the original was not mutated by it */
+ self::assertNotSame($format, $derived);
+ self::assertSame([['tenant_id']], EmbeddedMetricPayload::from(payload: $payload)->dimensions());
+ }
+
+ #[DataProvider('unboundedDimensionProvider')]
+
+ public function testFormatWhenDimensionWasDeclaredUnboundedThenRefusesAndNamesTheAlternative(
+ string $dimension
+ ): void {
+ /** @Given a format told which names the consumer treats as unbounded */
+ $format = EmbeddedMetricFormat::default()->withUnboundedDimensions('user_id', 'tenant_id');
+
+ /** @And a counter broken down by one of them */
+ $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist')
+ ->withDimension(name: $dimension, value: 'anything');
+
+ /** @Then the refusal names the dimension and points at the field */
+ $this->expectException(UnboundedDimension::class);
+ $this->expectExceptionMessage(
+ sprintf(
+ 'The dimension <%s> was declared unbounded. Emit it as a field of the same record instead.',
+ $dimension
+ )
+ );
+
+ /** @When rendering it */
+ $format->format(metric: $metric);
+ }
+
+ public static function unitProvider(): iterable
+ {
+ $tokens = [
+ 'bit' => 'Bits',
+ '1' => 'None',
+ 'By' => 'Bytes',
+ '{count}' => 'Count',
+ '%' => 'Percent',
+ 's' => 'Seconds'
+ ];
+
+ foreach (MetricUnit::cases() as $unit) {
+ yield $unit->value => ['unit' => $unit, 'token' => $tokens[$unit->value]];
+ }
+ }
+
+ public static function unboundedDimensionProvider(): iterable
+ {
+ yield 'user identifier' => ['dimension' => 'user_id'];
+ yield 'tenant identifier' => ['dimension' => 'tenant_id'];
+ }
+}
diff --git a/tests/InMemoryLoggerTest.php b/tests/Unit/InMemoryLoggerTest.php
similarity index 66%
rename from tests/InMemoryLoggerTest.php
rename to tests/Unit/InMemoryLoggerTest.php
index 468ff31..c971e1a 100644
--- a/tests/InMemoryLoggerTest.php
+++ b/tests/Unit/InMemoryLoggerTest.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\Logger;
+namespace Test\TinyBlocks\Logger\Unit;
use PHPUnit\Framework\TestCase;
+use TinyBlocks\Logger\Correlation;
use TinyBlocks\Logger\Exceptions\UnknownLogLevel;
use TinyBlocks\Logger\InMemoryLogger;
-use TinyBlocks\Logger\LogContext;
final class InMemoryLoggerTest extends TestCase
{
@@ -23,31 +23,6 @@ public function testEntriesWhenNothingLoggedThenHoldsNoEntry(): void
self::assertTrue($entries->isEmpty());
}
- public function testLogWhenContextDerivedThenBothInstancesSeeIt(): void
- {
- /** @Given an in-memory logger */
- $logger = InMemoryLogger::create();
-
- /** @And a contextual logger derived from it */
- $contextual = $logger->withContext(context: LogContext::from(correlationId: 'req-abc-123'));
-
- /** @When logging through the contextual logger */
- $contextual->error(message: 'payment.failed');
-
- /** @Then the entry carries the correlation context and is visible from the original logger */
- self::assertSame(
- [
- [
- 'key' => 'payment.failed',
- 'level' => 'ERROR',
- 'context' => 'req-abc-123',
- 'payload' => []
- ]
- ],
- $logger->entries()->toArray()
- );
- }
-
public function testLogWhenEntryRecordedThenKeepsLevelAndPayload(): void
{
/** @Given an in-memory logger */
@@ -60,10 +35,10 @@ public function testLogWhenEntryRecordedThenKeepsLevelAndPayload(): void
self::assertSame(
[
[
- 'key' => 'user.created',
- 'level' => 'INFO',
- 'context' => null,
- 'payload' => ['document' => '12345678900']
+ 'key' => 'user.created',
+ 'level' => 'INFO',
+ 'payload' => ['document' => '12345678900'],
+ 'correlation' => null
]
],
$logger->entries()->toArray()
@@ -81,4 +56,29 @@ public function testLogWhenLevelIsUnknownThenThrowsUnknownLogLevel(): void
/** @When logging at a level outside the supported set */
$logger->log('not-a-level', 'some.key');
}
+
+ public function testLogWhenCorrelationDerivedThenBothInstancesSeeIt(): void
+ {
+ /** @Given an in-memory logger */
+ $logger = InMemoryLogger::create();
+
+ /** @And a correlated logger derived from it */
+ $correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'req-abc-123'));
+
+ /** @When logging through the correlated logger */
+ $correlated->error(message: 'payment.failed');
+
+ /** @Then the entry carries the correlation and is visible from the original logger */
+ self::assertSame(
+ [
+ [
+ 'key' => 'payment.failed',
+ 'level' => 'ERROR',
+ 'payload' => [],
+ 'correlation' => 'req-abc-123'
+ ]
+ ],
+ $logger->entries()->toArray()
+ );
+ }
}
diff --git a/tests/InMemoryStream.php b/tests/Unit/InMemoryStream.php
similarity index 95%
rename from tests/InMemoryStream.php
rename to tests/Unit/InMemoryStream.php
index 4467687..8ac4482 100644
--- a/tests/InMemoryStream.php
+++ b/tests/Unit/InMemoryStream.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\Logger;
+namespace Test\TinyBlocks\Logger\Unit;
final class InMemoryStream
{
diff --git a/tests/LogLevelTest.php b/tests/Unit/LogLevelTest.php
similarity index 95%
rename from tests/LogLevelTest.php
rename to tests/Unit/LogLevelTest.php
index 5ebfb56..953fea4 100644
--- a/tests/LogLevelTest.php
+++ b/tests/Unit/LogLevelTest.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\Logger;
+namespace Test\TinyBlocks\Logger\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use TinyBlocks\Logger\LogLevel;
-use TinyBlocks\Logger\StructuredLogger;
+use TinyBlocks\Logger\StreamLogger;
final class LogLevelTest extends TestCase
{
@@ -26,7 +26,7 @@ protected function tearDown(): void
public function testLogWhenAtMinimumLevelThenWritesTheEntry(): void
{
/** @Given a structured logger that discards anything below warning */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'order-service')
->withMinimumLevel(minimumLevel: LogLevel::WARNING)
@@ -54,7 +54,7 @@ public function testSeverityWhenLevelGivenThenReturnsItsRank(LogLevel $level, in
public function testLogWhenBelowMinimumLevelThenWritesNothing(): void
{
/** @Given a structured logger that discards anything below warning */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'order-service')
->withMinimumLevel(minimumLevel: LogLevel::WARNING)
@@ -82,7 +82,7 @@ public function testIsAtLeastWhenSameLevelThenReachesThreshold(): void
public function testLogWhenLevelIsLowercaseThenResolvesTheLevel(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'order-service')
->build();
diff --git a/tests/Unit/MetricTest.php b/tests/Unit/MetricTest.php
new file mode 100644
index 0000000..a9f9f8e
--- /dev/null
+++ b/tests/Unit/MetricTest.php
@@ -0,0 +1,202 @@
+name->value);
+ self::assertSame(MetricUnit::COUNT, $metric->unit);
+ self::assertSame(1, $metric->value);
+ self::assertSame([], $metric->fields);
+ self::assertSame('Acme/Waitlist', $metric->namespace->value);
+ self::assertSame([], $metric->dimensions);
+ }
+
+ public function testWithFieldWhenTheNameIsBlankThenRefusesIt(): void
+ {
+ /** @Given a metric */
+ $metric = Metric::of(name: 'OfferAccepted', namespace: 'Acme/Waitlist');
+
+ /** @Then a field with no name is refused, for the same reason */
+ $this->expectException(BlankMetricIdentifier::class);
+ $this->expectExceptionMessage('The metric field name cannot be blank.');
+
+ /** @When carrying it */
+ $metric->withField(name: ' ', value: 'abc-123');
+ }
+
+ public function testWithDimensionWhenTheNameIsBlankThenRefusesIt(): void
+ {
+ /** @Given a metric */
+ $metric = Metric::of(name: 'OfferAccepted', namespace: 'Acme/Waitlist');
+
+ /** @Then a dimension with no name is refused, because it resolves to nothing downstream */
+ $this->expectException(BlankMetricIdentifier::class);
+ $this->expectExceptionMessage('The metric dimension name cannot be blank.');
+
+ /** @When declaring it */
+ $metric->withDimension(name: ' ', value: 'saas');
+ }
+
+ #[DataProvider('blankIdentifierProvider')]
+
+ public function testOfWhenAnIdentifierIsBlankThenRefusesAndNamesIt(
+ string $name,
+ string $namespace,
+ string $identifier
+ ): void {
+ /** @Given a name or a namespace that carries nothing */
+ /** @Then the refusal names which one is blank */
+ $this->expectException(BlankMetricIdentifier::class);
+ $this->expectExceptionMessage(sprintf('The metric %s cannot be blank.', $identifier));
+
+ /** @When creating the metric */
+ Metric::of(name: $name, namespace: $namespace);
+ }
+
+ public function testWithDimensionWhenTwoAddedThenBothSurviveInOrder(): void
+ {
+ /** @Given a counter */
+ $metric = Metric::of(name: 'SubscriptionRenewed', namespace: 'Acme/Subscription');
+
+ /** @When adding two dimensions in sequence */
+ $derived = $metric
+ ->withDimension(name: 'subscription_type', value: 'saas')
+ ->withDimension(name: 'billing_cycle', value: 'monthly');
+
+ /** @Then the second does not replace the first, and the original carries neither */
+ self::assertSame('saas', $derived->dimensions['subscription_type']->value);
+ self::assertSame('monthly', $derived->dimensions['billing_cycle']->value);
+ self::assertSame([], $metric->dimensions);
+ }
+
+ public function testOfWhenMeasuredThenDerivesFromTheCounterItStartsAs(): void
+ {
+ /** @Given a measurement that is not a count */
+ /** @When deriving it from the only factory */
+ $metric = Metric::of(name: 'OfferResponseTime', namespace: 'Acme/Waitlist')
+ ->withUnit(unit: MetricUnit::SECONDS)
+ ->withValue(value: 12.5);
+
+ /** @Then both survive, and a duration is seconds and never a prefixed unit */
+ self::assertSame(12.5, $metric->value);
+ self::assertSame(MetricUnit::SECONDS, $metric->unit);
+ }
+
+ public function testWithDimensionWhenNameRepeatedThenTheLastValueWins(): void
+ {
+ /** @Given a counter already broken down by a dimension */
+ $metric = Metric::of(name: 'SubscriptionCanceled', namespace: 'Acme/Subscription')
+ ->withDimension(name: 'subscription_type', value: 'saas');
+
+ /** @When the same name is declared again */
+ $derived = $metric->withDimension(name: 'subscription_type', value: 'b2c');
+
+ /** @Then the copy carries the last value, without duplicating the name */
+ self::assertSame(['subscription_type'], array_keys($derived->dimensions));
+ self::assertSame('b2c', $derived->dimensions['subscription_type']->value);
+ }
+
+ public function testWithDimensionWhenTheNameIsAlreadyAFieldThenRefusesIt(): void
+ {
+ /** @Given a counter already carrying a field */
+ $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist')
+ ->withField(name: 'plan', value: 'saas');
+
+ /** @Then the refusal names the identifier both would land under, in this order too */
+ $this->expectException(DuplicateMetricIdentifier::class);
+ $this->expectExceptionMessage(
+ 'The name is carried by a field and by a dimension at once. Both land at the root of the '
+ . 'same record, so one would silently replace the other.'
+ );
+
+ /** @When declaring a dimension under the same name */
+ $metric->withDimension(name: 'plan', value: 'b2c');
+ }
+
+ public function testWithFieldWhenTheNameIsAlreadyADimensionThenRefusesIt(): void
+ {
+ /** @Given a counter already broken down by a dimension */
+ $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist')
+ ->withDimension(name: 'plan', value: 'saas');
+
+ /** @Then the refusal names the identifier both would land under */
+ $this->expectException(DuplicateMetricIdentifier::class);
+ $this->expectExceptionMessage(
+ 'The name is carried by a field and by a dimension at once. Both land at the root of the '
+ . 'same record, so one would silently replace the other.'
+ );
+
+ /** @When carrying a field under the same name */
+ $metric->withField(name: 'plan', value: 'b2c');
+ }
+
+ public function testWithFieldWhenGranularThenTravelsAsFieldAndNotAsDimension(): void
+ {
+ /** @Given a counter */
+ $metric = Metric::of(name: 'OfferExpired', namespace: 'Acme/Waitlist');
+
+ /** @When carrying granular attributes */
+ $derived = $metric
+ ->withField(name: 'tenant_id', value: 'abc-123')
+ ->withField(name: 'attempt', value: 2);
+
+ /** @Then they are fields, the dimension set stays empty, and the original carries neither */
+ self::assertSame('abc-123', $derived->fields['tenant_id']->value);
+ self::assertSame(2, $derived->fields['attempt']->value);
+ self::assertSame([], $derived->dimensions);
+ self::assertSame([], $metric->fields);
+ }
+
+ public function testWithUnitWhenReplacedThenCopyCarriesItAndOriginalIsUnchanged(): void
+ {
+ /** @Given a counter */
+ $metric = Metric::of(name: 'AppointmentDuration', namespace: 'Acme/Schedule');
+
+ /** @When deriving a copy in another unit */
+ $derived = $metric->withUnit(unit: MetricUnit::SECONDS);
+
+ /** @Then the copy carries it and the original stays a count */
+ self::assertSame(MetricUnit::SECONDS, $derived->unit);
+ self::assertSame(MetricUnit::COUNT, $metric->unit);
+ }
+
+ public function testWithValueWhenReplacedThenCopyCarriesItAndOriginalIsUnchanged(): void
+ {
+ /** @Given a counter */
+ $metric = Metric::of(name: 'AppointmentCompleted', namespace: 'Acme/Schedule');
+
+ /** @When deriving a copy with another value */
+ $derived = $metric->withValue(value: 7);
+
+ /** @Then the copy carries it and the original still counts one */
+ self::assertSame(7, $derived->value);
+ self::assertSame(1, $metric->value);
+ }
+
+ public static function blankIdentifierProvider(): array
+ {
+ return [
+ 'empty name' => ['', 'Acme/Waitlist', 'name'],
+ 'blank name' => [' ', 'Acme/Waitlist', 'name'],
+ 'empty namespace' => ['OfferAccepted', '', 'namespace'],
+ 'blank namespace' => ['OfferAccepted', "\t", 'namespace']
+ ];
+ }
+}
diff --git a/tests/Unit/RedactionsTest.php b/tests/Unit/RedactionsTest.php
new file mode 100644
index 0000000..e586829
--- /dev/null
+++ b/tests/Unit/RedactionsTest.php
@@ -0,0 +1,828 @@
+logStream = InMemoryStream::create();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->logStream->close();
+ }
+
+ public function testRedactWhenVisibleEdgesThenKeepsBothEnds(): void
+ {
+ /** @Given a structured logger keeping the head and the tail of the phone */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'contact-service')
+ ->withRedactions(
+ GenericRedaction::masking(
+ mask: Mask::proportional(),
+ fields: ['phone'],
+ visibility: Visibility::edges(prefixLength: 5, suffixLength: 4)
+ )
+ )
+ ->build();
+
+ /** @When logging with a phone field */
+ $logger->info(message: 'contact.updated', context: ['phone' => '+5511999887766']);
+
+ /** @Then both edges of the value stay visible and the middle is masked */
+ self::assertStringContainsString('data={"phone":"+5511*****7766"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenVisibleWordsThenMasksEachWordApart(): void
+ {
+ /** @Given a structured logger masking each word of a name with a fixed-width mask */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'user-service')
+ ->withRedactions(
+ GenericRedaction::masking(
+ mask: Mask::fixed(length: 3),
+ fields: ['name'],
+ visibility: Visibility::words(prefixLength: 2)
+ )
+ )
+ ->build();
+
+ /** @When logging with a name made of several words */
+ $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']);
+
+ /** @Then every word keeps its own visible prefix */
+ self::assertStringContainsString('data={"name":"Gu*** Fr***"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenFullMaskThenHidesTheValueLength(): void
+ {
+ /** @Given a structured logger masking two fields of different lengths with a fixed mask */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'audit-service')
+ ->withRedactions(
+ GenericRedaction::masking(mask: Mask::fixed(length: 4), fields: ['user_agent', 'session_id'])
+ )
+ ->build();
+
+ /** @When logging with both fields and an unrelated one */
+ $logger->info(message: 'request.received', context: [
+ 'user_agent' => 'Mozilla/5.0',
+ 'session_id' => 'abc',
+ 'route' => '/v1/users'
+ ]);
+
+ /** @Then both masks have the same width regardless of the original length */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"user_agent":"****"', $output);
+ self::assertStringContainsString('"session_id":"****"', $output);
+ self::assertStringContainsString('"route":"/v1/users"', $output);
+ }
+
+ public function testRedactWhenValueIsNumericThenMasksItAsText(): void
+ {
+ /** @Given a structured logger with document redaction */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'kyc-service')
+ ->withRedactions(DocumentRedaction::default())
+ ->build();
+
+ /** @When logging with a document decoded from JSON as an integer */
+ $logger->info(message: 'kyc.verified', context: ['document' => 12345678900, 'amount' => 100]);
+
+ /** @Then the numeric document is masked as text and the unrelated number is preserved */
+ self::assertStringContainsString('data={"document":"*********00","amount":100}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenListOfValuesThenMasksEachElement(): void
+ {
+ /** @Given a structured logger with phone redaction */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'notification-service')
+ ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 4))
+ ->build();
+
+ /** @When logging with a sensitive field holding a list of values */
+ $logger->info(message: 'sms.queued', context: ['phone' => ['+5511999887766', '+5521988776655']]);
+
+ /** @Then every element of the list is masked */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('data={"phone":["**********7766","**********6655"]}', $output);
+ self::assertStringNotContainsString('+5511999887766', $output);
+ }
+
+ public function testRedactWhenValueIsNullThenLeavesItUntouched(): void
+ {
+ /** @Given a structured logger with document redaction */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'kyc-service')
+ ->withRedactions(DocumentRedaction::default())
+ ->build();
+
+ /** @When logging with a sensitive field holding no value */
+ $logger->info(message: 'kyc.skipped', context: ['document' => null, 'status' => 'pending']);
+
+ /** @Then the absent value is written as is */
+ self::assertStringContainsString('data={"document":null,"status":"pending"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenWildcardFieldThenMasksEveryMatch(): void
+ {
+ /** @Given a structured logger with a name redaction targeting a field name pattern */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'messaging-service')
+ ->withRedactions(NameRedaction::from(fields: ['*_name'], visiblePrefixLength: 2))
+ ->build();
+
+ /** @When logging with a matching field, a field that does not match, and a list */
+ $logger->info(message: 'message.dispatched', context: [
+ 'client_name' => 'João Silva',
+ 'name' => 'Maria',
+ 'tags' => ['first', 'retry']
+ ]);
+
+ /** @Then only the matching field is masked */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"client_name":"Jo********"', $output);
+ self::assertStringContainsString('"name":"Maria"', $output);
+
+ /** @And the numeric keys of the list are matched against the pattern without failing */
+ self::assertStringContainsString('"tags":["first","retry"]', $output);
+ }
+
+ public function testRedactWhenScopedThenMasksOnlyInsideTheScope(): void
+ {
+ /** @Given a structured logger masking the value field only under a document parent */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(
+ GenericRedaction::under(
+ parent: 'document',
+ redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2)
+ )
+ )
+ ->build();
+
+ /** @When logging with the same field name inside and outside the scope */
+ $logger->info(message: 'payment.created', context: [
+ 'document' => ['type' => 'cpf', 'value' => '12345678901'],
+ 'metadata' => ['value' => 'operational-marker'],
+ 'charge' => ['document' => ['value' => '98765432100']]
+ ]);
+
+ /** @Then the value inside the scope is masked */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"document":{"type":"cpf","value":"*********01"}', $output);
+
+ /** @And the value outside the scope is preserved */
+ self::assertStringContainsString('"metadata":{"value":"operational-marker"}', $output);
+
+ /** @And the scope is honored at any depth */
+ self::assertStringContainsString('"charge":{"document":{"value":"*********00"}}', $output);
+ }
+
+ public function testRedactWhenPatternMatchesTextThenMasksTheMatch(): void
+ {
+ /** @Given a structured logger masking eleven-digit runs found in any text */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'error-service')
+ ->withRedactions(GenericRedaction::replacing(pattern: '/\d{11}/', replacement: '[REDACTED]'))
+ ->build();
+
+ /** @When logging an exception message and a URI carrying the document */
+ $logger->error(message: 'request.failed', context: [
+ 'message' => 'Document 12345678901 is invalid.',
+ 'attempts' => 3,
+ 'nested' => ['uri' => '/clients/12345678901']
+ ]);
+
+ /** @Then the match inside the free text is replaced */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"message":"Document [REDACTED] is invalid."', $output);
+
+ /** @And the match inside the nested URI is replaced */
+ self::assertStringContainsString('"nested":{"uri":"/clients/[REDACTED]"}', $output);
+
+ /** @And values that are not text are left alone */
+ self::assertStringContainsString('"attempts":3', $output);
+ }
+
+ public function testRedactWhenAllowListThenMasksEveryFieldOutsideIt(): void
+ {
+ /** @Given a structured logger allowing only the city and the state under the address */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(
+ GenericRedaction::under(
+ parent: 'address',
+ redaction: GenericRedaction::keeping(fields: ['city', 'state'], mask: Mask::fixed(length: 3))
+ )
+ )
+ ->build();
+
+ /** @When logging with an address and a sibling field outside the scope */
+ $logger->info(message: 'payment.created', context: [
+ 'address' => ['city' => 'São Paulo', 'state' => 'BR-SP', 'street' => 'Rua Example'],
+ 'name' => 'Maria'
+ ]);
+
+ /** @Then the allowed fields survive and everything else in the scope is masked */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"city":"São Paulo","state":"BR-SP","street":"***"', $output);
+
+ /** @And fields outside the scope are untouched */
+ self::assertStringContainsString('"name":"Maria"', $output);
+ }
+
+ public function testRedactWhenScopeHoldsScalarThenLeavesItUntouched(): void
+ {
+ /** @Given a structured logger masking the value field only under a document parent */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(
+ GenericRedaction::under(
+ parent: 'document',
+ redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2)
+ )
+ )
+ ->build();
+
+ /** @When logging with the scope parent holding a scalar instead of a sub payload */
+ $logger->info(message: 'payment.created', context: ['document' => 'plain-value']);
+
+ /** @Then the scalar is written as is */
+ self::assertStringContainsString('data={"document":"plain-value"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenCommonSecretsThenMasksEverySecretField(): void
+ {
+ /** @Given a structured logger with the common secrets redaction */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'auth-service')
+ ->withRedactions(SecretRedaction::default())
+ ->build();
+
+ /** @When logging with one field per secret naming convention */
+ $logger->info(message: 'auth.check', context: [
+ 'user_id' => 'u-1',
+ 'credentials' => 'basic',
+ 'access_token' => 'at-1',
+ 'authorization' => 'Bearer x',
+ 'client_secret' => 'cs-1',
+ 'stripe_api_key' => 'ak-1',
+ 'rsa_private_key' => 'pk-1',
+ 'current_password' => 'pw-1'
+ ]);
+
+ /** @Then every secret field is fully masked */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"credentials":"********"', $output);
+ self::assertStringContainsString('"access_token":"********"', $output);
+ self::assertStringContainsString('"authorization":"********"', $output);
+ self::assertStringContainsString('"client_secret":"********"', $output);
+ self::assertStringContainsString('"stripe_api_key":"********"', $output);
+ self::assertStringContainsString('"rsa_private_key":"********"', $output);
+ self::assertStringContainsString('"current_password":"********"', $output);
+
+ /** @And fields outside the convention are preserved */
+ self::assertStringContainsString('"user_id":"u-1"', $output);
+ }
+
+ public function testRedactWhenFieldsRemovedThenDropsThemFromTheEntry(): void
+ {
+ /** @Given a structured logger dropping the trace and every token field */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'error-service')
+ ->withRedactions(GenericRedaction::removing(fields: ['trace', '*_token']))
+ ->build();
+
+ /** @When logging with those fields at the root and nested */
+ $logger->error(message: 'request.failed', context: [
+ 'message' => 'boom',
+ 'trace' => '#0 stack',
+ 'nested' => ['access_token' => 'at-1', 'id' => '7']
+ ]);
+
+ /** @Then the dropped fields leave no trace in the entry */
+ self::assertStringContainsString('data={"message":"boom","nested":{"id":"7"}}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenSeparatorsKeptThenPreservesPunctuation(): void
+ {
+ /** @Given a structured logger masking a postal code while keeping its separators */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(
+ GenericRedaction::masking(
+ mask: Mask::preservingSeparators(),
+ fields: ['postal_code', 'reference', 'coordinates'],
+ visibility: Visibility::edges(prefixLength: 3)
+ )
+ )
+ ->build();
+
+ /** @When logging with a formatted postal code, an accented reference, and a coordinate */
+ $logger->info(message: 'address.saved', context: [
+ 'postal_code' => '01310-100',
+ 'reference' => 'Rua Açaí, 42',
+ 'coordinates' => 'S 23° 33'
+ ]);
+
+ /** @Then digits are hidden and the separator survives */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"postal_code":"013**-***"', $output);
+
+ /** @And accented letters are hidden as single characters */
+ self::assertStringContainsString('"reference":"Rua ****, **"', $output);
+
+ /** @And symbols outside the letter and digit categories survive */
+ self::assertStringContainsString('"coordinates":"S 2*° **"', $output);
+ }
+
+ public function testRedactWhenMapOfValuesThenDescendsInsteadOfMasking(): void
+ {
+ /** @Given a structured logger with document redaction */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'kyc-service')
+ ->withRedactions(DocumentRedaction::default())
+ ->build();
+
+ /** @When logging with a sensitive field holding a map instead of a value */
+ $logger->info(message: 'kyc.verified', context: [
+ 'document' => ['type' => 'cnpj', 'value' => '12345678000199', 'issued_at' => '2020-01-01']
+ ]);
+
+ /** @Then the map is descended into, so the fields that carry their own meaning survive */
+ self::assertStringContainsString(
+ 'data={"document":{"type":"cnpj","value":"12345678000199","issued_at":"2020-01-01"}}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenPatternIsInvalidThenThrowsInvalidPattern(): void
+ {
+ /** @Then an exception describing the rejected pattern is raised */
+ $this->expectException(MalformedRedactionPattern::class);
+
+ /** @And the message names the offending pattern */
+ $this->expectExceptionMessage('Pattern is not a valid regular expression: /[unclosed/.');
+
+ /** @When building a redaction from a pattern the engine cannot compile */
+ GenericRedaction::replacing(pattern: '/[unclosed/', replacement: '[REDACTED]');
+ }
+
+ public function testRedactWhenPrefixIsNegativeThenThrowsNegativeLength(): void
+ {
+ /** @Then an exception describing the rejected configuration is raised */
+ $this->expectException(NegativeVisibleLength::class);
+
+ /** @And the message names both visible lengths */
+ $this->expectExceptionMessage('Visible length cannot be negative, got prefix -1 and suffix 4.');
+
+ /** @When building a redaction that leaves a negative number of leading characters visible */
+ GenericRedaction::masking(
+ mask: Mask::proportional(),
+ fields: ['phone'],
+ visibility: Visibility::edges(prefixLength: -1, suffixLength: 4)
+ );
+ }
+
+ public function testRedactWhenSuffixIsNegativeThenThrowsNegativeLength(): void
+ {
+ /** @Then an exception describing the rejected configuration is raised */
+ $this->expectException(NegativeVisibleLength::class);
+
+ /** @And the message names both visible lengths */
+ $this->expectExceptionMessage('Visible length cannot be negative, got prefix 2 and suffix -1.');
+
+ /** @When building a redaction that leaves a negative number of trailing characters visible */
+ GenericRedaction::masking(
+ mask: Mask::proportional(),
+ fields: ['phone'],
+ visibility: Visibility::edges(prefixLength: 2, suffixLength: -1)
+ );
+ }
+
+ public function testRedactWhenVisibleWordsHaveNoPrefixThenMasksEveryWordHead(): void
+ {
+ /** @Given a structured logger masking each word down to its last character */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'user-service')
+ ->withRedactions(
+ GenericRedaction::masking(
+ mask: Mask::proportional(),
+ fields: ['name'],
+ visibility: Visibility::words(suffixLength: 1)
+ )
+ )
+ ->build();
+
+ /** @When logging with a name made of several words */
+ $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']);
+
+ /** @Then every word keeps only its trailing character */
+ self::assertStringContainsString('data={"name":"******o ****e"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenUrlCarriesAQueryStringThenOnlyThePathSurvives(): void
+ {
+ /** @Given a structured logger dropping every query string */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'client-gateway')
+ ->withRedactions(QueryStringRedaction::default())
+ ->build();
+
+ /** @When logging a route, a URL quoted inside a message, and a path with no query string */
+ $logger->info(message: 'request.received', context: [
+ 'uri' => '/v1/clients?document=12345678900&page=2',
+ 'route' => '/v1/clients',
+ 'message' => 'GET https://api.example.com/v1/clients?token=abc123 failed'
+ ]);
+
+ /** @Then what follows the question mark is gone and the path is intact in all of them */
+ self::assertStringContainsString(
+ 'data={"uri":"/v1/clients?","route":"/v1/clients",'
+ . '"message":"GET https://api.example.com/v1/clients? failed"}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenFilterComparesThenOperandIsMaskedAndOperatorSurvives(): void
+ {
+ /** @Given a structured logger masking filter operands */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'ledger')
+ ->withRedactions(FilterExpressionRedaction::default())
+ ->build();
+
+ /** @When logging filters that compare, negate, and range over values */
+ $logger->info(message: 'query.received', context: [
+ 'equality' => 'status==active;document==12345678900',
+ 'negation' => 'status!=canceled',
+ 'range' => 'created_at=ge=2026-01-01,created_at=le=2026-02-01'
+ ]);
+
+ /** @Then every operand is masked and every field and operator stays readable */
+ self::assertStringContainsString(
+ 'data={"equality":"status==********;document==********","negation":"status!=********",'
+ . '"range":"created_at=ge=********,created_at=le=********"}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenFilterComparesAgainstAListThenTheWholeListIsMasked(): void
+ {
+ /** @Given a structured logger masking filter operands */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'ledger')
+ ->withRedactions(FilterExpressionRedaction::default())
+ ->build();
+
+ /** @When logging a filter whose operand is a parenthesized list */
+ $logger->info(message: 'query.received', context: [
+ 'filter' => 'document=in=(12345678900,98765432100);status=out=(canceled,failed)'
+ ]);
+
+ /** @Then no value inside the parentheses survives, which is where a list leaks */
+ self::assertStringContainsString(
+ 'data={"filter":"document=in=********;status=out=********"}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenValueCarriesNoComparisonThenLeavesItUntouched(): void
+ {
+ /** @Given a structured logger masking filter operands */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'ledger')
+ ->withRedactions(FilterExpressionRedaction::default())
+ ->build();
+
+ /** @When logging a value that carries no comparison at all */
+ $logger->info(message: 'query.received', context: ['sort' => 'created_at desc']);
+
+ /** @Then it travels unchanged, because there is no operand to mask */
+ self::assertStringContainsString('data={"sort":"created_at desc"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenEmailPrefixIsGivenThenItOverridesTheDefaultWindow(): void
+ {
+ /** @Given a structured logger showing more of the local part than the default two characters */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'user-service')
+ ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 4))
+ ->build();
+
+ /** @When logging an address */
+ $logger->info(message: 'user.created', context: ['email' => 'maria.silva@example.com']);
+
+ /** @Then four characters of the local part survive, and not the two the default would keep */
+ self::assertStringContainsString('data={"email":"mari*******@example.com"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenQueryParametersAreAllowedThenOnlyTheOthersAreMasked(): void
+ {
+ /** @Given a structured logger keeping the query parameters this API answers by */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'ledger')
+ ->withRedactions(QueryParametersRedaction::keeping(fields: ['sort', 'page*']))
+ ->build();
+
+ /** @When logging a request carrying an allowed parameter, a wildcard match, and one nobody declared */
+ $logger->info(message: 'request.received', context: [
+ 'uri' => '/v1/clients',
+ 'query_parameters' => ['sort' => 'created_at', 'page_size' => '20', 'document' => '12345678900'],
+ 'body' => ['document' => '12345678900']
+ ]);
+
+ /** @Then the undeclared parameter is masked, the allowed ones survive, and nothing outside the branch moves */
+ self::assertStringContainsString(
+ 'data={"uri":"/v1/clients","query_parameters":{"sort":"created_at","page_size":"20",'
+ . '"document":"********"},"body":{"document":"12345678900"}}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenNoQueryParameterIsAllowedThenEveryOneOfThemIsMasked(): void
+ {
+ /** @Given a structured logger allowing no query parameter at all */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'identity')
+ ->withRedactions(QueryParametersRedaction::default())
+ ->build();
+
+ /** @When logging a request carrying two of them */
+ $logger->info(message: 'request.received', context: [
+ 'query_parameters' => ['sort' => 'created_at', 'document' => '12345678900']
+ ]);
+
+ /** @Then both are masked, because a parameter is readable only when it is named */
+ self::assertStringContainsString(
+ 'data={"query_parameters":{"sort":"********","document":"********"}}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenBirthDateTravelsUnderAnotherNameThenTheDefaultCoversIt(): void
+ {
+ /** @Given a structured logger with the default birth date redaction */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(BirthDateRedaction::default())
+ ->build();
+
+ /** @When logging the same date under each name it travels by, next to a field that only looks alike */
+ $logger->info(message: 'payer.registered', context: [
+ 'birth_date' => '1990-07-21',
+ 'birthdate' => '1990-07-21',
+ 'date_of_birth' => '1990-07-21',
+ 'birthplace' => 'São Paulo',
+ 'plan' => 'saas'
+ ]);
+
+ /** @Then the year and the separators survive in each of them, and the lookalike is untouched */
+ self::assertStringContainsString(
+ 'data={"birth_date":"1990-**-**","birthdate":"1990-**-**","date_of_birth":"1990-**-**",'
+ . '"birthplace":"São Paulo","plan":"saas"}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenBirthDateWindowIsGivenThenItOverridesTheDefault(): void
+ {
+ /** @Given a structured logger hiding the year as well */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(BirthDateRedaction::from(fields: ['birth_date'], visiblePrefixLength: 0))
+ ->build();
+
+ /** @When logging a payer carrying a date of birth */
+ $logger->info(message: 'payer.registered', context: ['birth_date' => '1990-07-21']);
+
+ /** @Then nothing of the date survives but its shape, and not the year the default would keep */
+ self::assertStringContainsString('data={"birth_date":"****-**-**"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenPostalCodeThenKeepsTheLeadingCharactersOfTheRegion(): void
+ {
+ /** @Given a structured logger with postal code redaction over three shapes of code */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(PostalCodeRedaction::from(fields: ['postal_code', 'zip', 'outward']))
+ ->build();
+
+ /** @When logging an address carrying each of them */
+ $logger->info(message: 'address.given', context: [
+ 'postal_code' => '01310-100',
+ 'zip' => '94103',
+ 'outward' => 'SW1A 1AA'
+ ]);
+
+ /** @Then each keeps the leading characters that name the region, with the separators preserved */
+ self::assertStringContainsString(
+ 'data={"postal_code":"013**-***","zip":"941**","outward":"SW1* ***"}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenPostalCodeTravelsUnderAnotherNameThenTheDefaultStillCoversIt(): void
+ {
+ /** @Given a structured logger with the default postal code redaction */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(PostalCodeRedaction::default())
+ ->build();
+
+ /** @When logging the same value under each name it travels by, next to fields that only look alike */
+ $logger->info(message: 'address.given', context: [
+ 'postal_code' => '01310-100',
+ 'postcode' => '01310-100',
+ 'zip_code' => '01310-100',
+ 'zip' => 'invoices.zip',
+ 'area_code' => '11',
+ 'city' => 'São Paulo'
+ ]);
+
+ /** @Then every name of the postal code is masked past the region, and the lookalikes are not */
+ self::assertStringContainsString(
+ 'data={"postal_code":"013**-***","postcode":"013**-***","zip_code":"013**-***",'
+ . '"zip":"invoices.zip","area_code":"11","city":"São Paulo"}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenPostalCodeWindowIsGivenThenItOverridesTheDefault(): void
+ {
+ /** @Given a structured logger keeping more of the code than the default three characters */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(PostalCodeRedaction::from(fields: ['postal_code'], visiblePrefixLength: 5))
+ ->build();
+
+ /** @When logging an address */
+ $logger->info(message: 'address.given', context: ['postal_code' => '01310-100']);
+
+ /** @Then five characters survive, and not the three the default would keep */
+ self::assertStringContainsString('data={"postal_code":"01310-***"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenFieldIsSpelledInAnotherCaseThenTheSameRuleCoversIt(): void
+ {
+ /** @Given a structured logger with the strategies that name their fields in snake case */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(SecretRedaction::default(), PostalCodeRedaction::default(), BirthDateRedaction::default())
+ ->build();
+
+ /** @When logging a payload that spells the same names in camel case and with an acronym */
+ $logger->info(message: 'payer.registered', context: [
+ 'postalCode' => '01310-100',
+ 'birthDate' => '1990-07-21',
+ 'accessToken' => 'at-1',
+ 'debitCardToken' => 'tok-1',
+ 'APIKey' => 'ak-1',
+ 'plan' => 'saas'
+ ]);
+
+ /** @Then every spelling of a covered name is masked, and the field nobody named survives */
+ self::assertStringContainsString(
+ 'data={"postalCode":"013**-***","birthDate":"1990-**-**","accessToken":"********",'
+ . '"debitCardToken":"********","APIKey":"********","plan":"saas"}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenPatternRequiresASeparatorThenTheBareFieldStaysOutOfIt(): void
+ {
+ /** @Given a structured logger covering only field names that carry a qualifier */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'user-service')
+ ->withRedactions(NameRedaction::from(fields: ['*_name']))
+ ->build();
+
+ /** @When logging the qualified name in two spellings, next to the bare one */
+ $logger->info(message: 'user.created', context: [
+ 'client_name' => 'Maria Silva',
+ 'clientName' => 'Maria Silva',
+ 'name' => 'Maria Silva'
+ ]);
+
+ /** @Then both spellings of the qualified name are masked and the bare one is not */
+ self::assertStringContainsString(
+ 'data={"client_name":"Ma*********","clientName":"Ma*********","name":"Maria Silva"}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenSecretFieldsFollowTheNamingConventionThenEachIsMasked(): void
+ {
+ /** @Given a structured logger with the default secret redaction */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'identity')
+ ->withRedactions(SecretRedaction::default())
+ ->build();
+
+ /** @When logging fields that carry credentials under the conventional names */
+ $logger->info(message: 'session.started', context: [
+ 'access_token' => 'eyJhbGciOi',
+ 'client_secret' => 'super-secret',
+ 'authorization' => 'Bearer abc123',
+ 'credentials' => 'user:password',
+ 'private_key' => '-----BEGIN',
+ 'api_key' => 'ak_live_1',
+ 'role' => 'owner'
+ ]);
+
+ /** @Then each of them is masked to the same width and the field that is not a secret survives */
+ self::assertStringContainsString(
+ 'data={"access_token":"********","client_secret":"********","authorization":"********",'
+ . '"credentials":"********","private_key":"********","api_key":"********","role":"owner"}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenAllowListAndNullValueThenLeavesItUntouched(): void
+ {
+ /** @Given a structured logger allowing only the city field */
+ $logger = StreamLogger::builder()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(GenericRedaction::keeping(fields: ['city'], mask: Mask::fixed(length: 3)))
+ ->build();
+
+ /** @When logging with an absent value and a nested allowed field */
+ $logger->info(message: 'address.saved', context: [
+ 'city' => 'São Paulo',
+ 'street' => null,
+ 'nested' => ['city' => 'Rio', 'label' => 'x']
+ ]);
+
+ /** @Then the absent value is written as is and the nested allow list still applies */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"city":"São Paulo","street":null', $output);
+ self::assertStringContainsString('"nested":{"city":"Rio","label":"***"}', $output);
+ }
+}
diff --git a/tests/StructuredLoggerTest.php b/tests/Unit/StreamLoggerTest.php
similarity index 91%
rename from tests/StructuredLoggerTest.php
rename to tests/Unit/StreamLoggerTest.php
index 33aac77..c175e37 100644
--- a/tests/StructuredLoggerTest.php
+++ b/tests/Unit/StreamLoggerTest.php
@@ -2,21 +2,23 @@
declare(strict_types=1);
-namespace Test\TinyBlocks\Logger;
+namespace Test\TinyBlocks\Logger\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use TinyBlocks\Logger\Correlation;
use TinyBlocks\Logger\Exceptions\UnknownLogLevel;
-use TinyBlocks\Logger\LogContext;
use TinyBlocks\Logger\Logger;
use TinyBlocks\Logger\Redactions\DocumentRedaction;
use TinyBlocks\Logger\Redactions\EmailRedaction;
+use TinyBlocks\Logger\Redactions\GenericRedaction;
+use TinyBlocks\Logger\Redactions\Mask;
use TinyBlocks\Logger\Redactions\NameRedaction;
-use TinyBlocks\Logger\Redactions\PasswordRedaction;
use TinyBlocks\Logger\Redactions\PhoneRedaction;
-use TinyBlocks\Logger\StructuredLogger;
+use TinyBlocks\Logger\Redactions\SecretRedaction;
+use TinyBlocks\Logger\StreamLogger;
-final class StructuredLoggerTest extends TestCase
+final class StreamLoggerTest extends TestCase
{
private InMemoryStream $logStream;
@@ -33,7 +35,7 @@ protected function tearDown(): void
public function testLogWhenDebugEntryThenWritesDebugLevel(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'debug-service')
->build();
@@ -51,7 +53,7 @@ public function testLogWhenDebugEntryThenWritesDebugLevel(): void
public function testLogWhenErrorEntryThenWritesErrorLevel(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'payment-service')
->build();
@@ -69,10 +71,10 @@ public function testLogWhenErrorEntryThenWritesErrorLevel(): void
public function testRedactWhenShortPasswordThenMasksFully(): void
{
/** @Given a structured logger with password redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'auth-service')
- ->withRedactions(PasswordRedaction::default())
+ ->withRedactions(SecretRedaction::default())
->build();
/** @When logging with a short password */
@@ -88,7 +90,7 @@ public function testRedactWhenShortPasswordThenMasksFully(): void
public function testLogWhenInfoEntryThenWritesLevelAndData(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'account-service')
->build();
@@ -108,7 +110,7 @@ public function testLogWhenInfoEntryThenWritesLevelAndData(): void
public function testLogWhenNoContextThenCorrelationIdIsEmpty(): void
{
/** @Given a structured logger without any context */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'no-context-service')
->build();
@@ -123,7 +125,7 @@ public function testLogWhenNoContextThenCorrelationIdIsEmpty(): void
public function testLogWhenNoPayloadThenWritesEmptyDataArray(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'minimal-service')
->build();
@@ -138,7 +140,7 @@ public function testLogWhenNoPayloadThenWritesEmptyDataArray(): void
public function testRedactWhenNameFieldThenMasksAllButPrefix(): void
{
/** @Given a structured logger with name redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(NameRedaction::default())
@@ -158,7 +160,7 @@ public function testRedactWhenNameFieldThenMasksAllButPrefix(): void
public function testLogWhenWarningEntryThenWritesWarningLevel(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'inventory-service')
->build();
@@ -176,7 +178,7 @@ public function testLogWhenWarningEntryThenWritesWarningLevel(): void
public function testRedactWhenMultipleNameFieldsThenMasksEach(): void
{
/** @Given a structured logger with name redaction targeting multiple field names */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(
@@ -208,7 +210,7 @@ public function testRedactWhenMultipleNameFieldsThenMasksEach(): void
public function testRedactWhenPhoneFieldThenMasksAllButSuffix(): void
{
/** @Given a structured logger with phone redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'notification-service')
->withRedactions(PhoneRedaction::default())
@@ -227,7 +229,7 @@ public function testRedactWhenPhoneFieldThenMasksAllButSuffix(): void
public function testRedactWhenSeveralRulesThenAppliesEachRule(): void
{
/** @Given a structured logger with multiple redactions */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(
@@ -248,7 +250,7 @@ public function testRedactWhenSeveralRulesThenAppliesEachRule(): void
/** @Then each field should be redacted according to its rule */
$output = $this->logStream->contents();
- self::assertStringContainsString('********900', $output);
+ self::assertStringContainsString('*********00', $output);
self::assertStringContainsString('jo**@example.com', $output);
self::assertStringContainsString('**********7766', $output);
self::assertStringContainsString('John', $output);
@@ -257,7 +259,7 @@ public function testRedactWhenSeveralRulesThenAppliesEachRule(): void
public function testLogWhenNoRedactionThenLeavesDataUnmodified(): void
{
/** @Given a structured logger without any redactions */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'simple-service')
->build();
@@ -272,7 +274,7 @@ public function testLogWhenNoRedactionThenLeavesDataUnmodified(): void
public function testRedactWhenMultipleEmailFieldsThenMasksEach(): void
{
/** @Given a structured logger with email redaction targeting multiple field names */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'notification-service')
->withRedactions(
@@ -301,7 +303,7 @@ public function testRedactWhenMultipleEmailFieldsThenMasksEach(): void
public function testRedactWhenMultiplePhoneFieldsThenMasksEach(): void
{
/** @Given a structured logger with phone redaction targeting multiple field names */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'contact-service')
->withRedactions(
@@ -330,7 +332,7 @@ public function testRedactWhenMultiplePhoneFieldsThenMasksEach(): void
public function testLogWhenCustomTemplateThenFollowsCustomFormat(): void
{
/** @Given a structured logger with a custom template */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n")
->withComponent(component: 'custom-service')
@@ -351,7 +353,7 @@ public function testLogWhenCustomTemplateThenFollowsCustomFormat(): void
public function testRedactWhenDocumentFieldThenMasksAllButSuffix(): void
{
/** @Given a structured logger with document redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'payment-service')
->withRedactions(DocumentRedaction::default())
@@ -363,7 +365,7 @@ public function testRedactWhenDocumentFieldThenMasksAllButSuffix(): void
/** @Then the document should be redacted showing only the last 3 characters */
$output = $this->logStream->contents();
- self::assertStringContainsString('********900', $output);
+ self::assertStringContainsString('*********00', $output);
self::assertStringNotContainsString('12345678900', $output);
self::assertStringContainsString('100.5', $output);
}
@@ -371,7 +373,7 @@ public function testRedactWhenDocumentFieldThenMasksAllButSuffix(): void
public function testRedactWhenEmailFieldThenMasksLocalPartSuffix(): void
{
/** @Given a structured logger with email redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(EmailRedaction::default())
@@ -390,7 +392,7 @@ public function testRedactWhenEmailFieldThenMasksLocalPartSuffix(): void
public function testRedactWhenNestedNameThenMasksAndKeepsSibling(): void
{
/** @Given a structured logger with name redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(NameRedaction::default())
@@ -417,7 +419,7 @@ public function testRedactWhenNestedNameThenMasksAndKeepsSibling(): void
public function testRedactWhenMultipleDocumentFieldsThenMasksEach(): void
{
/** @Given a structured logger with document redaction targeting multiple field names */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'kyc-service')
->withRedactions(DocumentRedaction::from(fields: ['cpf', 'cnpj'], visibleSuffixLength: 5))
@@ -441,10 +443,10 @@ public function testRedactWhenMultipleDocumentFieldsThenMasksEach(): void
public function testRedactWhenMultiplePasswordFieldsThenMasksEach(): void
{
/** @Given a structured logger with password redaction targeting multiple field names */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'auth-service')
- ->withRedactions(PasswordRedaction::from(fields: ['password', 'secret', 'token']))
+ ->withRedactions(SecretRedaction::from(fields: ['password', 'secret', 'token']))
->build();
/** @When logging with multiple password field variations */
@@ -465,7 +467,7 @@ public function testRedactWhenMultiplePasswordFieldsThenMasksEach(): void
public function testBuildWhenRedactionsAddedSeparatelyThenAllApply(): void
{
/** @Given a structured logger built with redactions added in separate calls */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'multi-call-service')
->withRedactions(DocumentRedaction::default())
@@ -481,7 +483,7 @@ public function testBuildWhenRedactionsAddedSeparatelyThenAllApply(): void
/** @Then both fields should be redacted */
$output = $this->logStream->contents();
- self::assertStringContainsString('********900', $output);
+ self::assertStringContainsString('*********00', $output);
self::assertStringContainsString('jo**@example.com', $output);
self::assertStringNotContainsString('12345678900', $output);
self::assertStringNotContainsString('john@example.com', $output);
@@ -490,7 +492,7 @@ public function testBuildWhenRedactionsAddedSeparatelyThenAllApply(): void
public function testLogWhenDefaultTemplateThenFollowsDefaultFormat(): void
{
/** @Given a structured logger using the default template */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'default-service')
->build();
@@ -510,7 +512,7 @@ public function testLogWhenDefaultTemplateThenFollowsDefaultFormat(): void
public function testLogWhenLevelIsUnknownThenThrowsUnknownLogLevel(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'level-service')
->build();
@@ -525,14 +527,14 @@ public function testLogWhenLevelIsUnknownThenThrowsUnknownLogLevel(): void
public function testRedactWhenAllRulesThenMasksEverySensitiveField(): void
{
/** @Given a structured logger with all available redactions */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'full-service')
->withRedactions(
DocumentRedaction::default(),
EmailRedaction::default(),
PhoneRedaction::default(),
- PasswordRedaction::default(),
+ SecretRedaction::default(),
NameRedaction::default()
)
->build();
@@ -550,7 +552,7 @@ public function testRedactWhenAllRulesThenMasksEverySensitiveField(): void
/** @Then each field should be redacted according to its rule */
$output = $this->logStream->contents();
- self::assertStringContainsString('********900', $output);
+ self::assertStringContainsString('*********00', $output);
self::assertStringContainsString('jo**@example.com', $output);
self::assertStringContainsString('**********7766', $output);
self::assertStringNotContainsString('s3cr3t!', $output);
@@ -562,7 +564,7 @@ public function testRedactWhenAllRulesThenMasksEverySensitiveField(): void
public function testLogWhenKeyHasNewlineThenEscapesControlCharacter(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'injection-test')
->build();
@@ -577,10 +579,10 @@ public function testLogWhenKeyHasNewlineThenEscapesControlCharacter(): void
public function testRedactWhenPasswordFieldThenMasksAndKeepsSibling(): void
{
/** @Given a structured logger with password redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'auth-service')
- ->withRedactions(PasswordRedaction::default())
+ ->withRedactions(SecretRedaction::default())
->build();
/** @When logging with a password field */
@@ -597,10 +599,10 @@ public function testRedactWhenPasswordFieldThenMasksAndKeepsSibling(): void
public function testRedactWhenNestedPasswordThenMasksAndKeepsSibling(): void
{
/** @Given a structured logger with password redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'auth-service')
- ->withRedactions(PasswordRedaction::default())
+ ->withRedactions(SecretRedaction::default())
->build();
/** @When logging with a nested structure containing a password field */
@@ -621,7 +623,7 @@ public function testRedactWhenNestedPasswordThenMasksAndKeepsSibling(): void
public function testBuildWhenNoStreamConfiguredThenUsesStderrFallback(): void
{
/** @Given a logger builder without an explicit stream */
- $builder = StructuredLogger::create()->withComponent(component: 'stderr-service');
+ $builder = StreamLogger::builder()->withComponent(component: 'stderr-service');
/** @When building the logger so the stream falls back to standard error */
$logger = $builder->build();
@@ -633,7 +635,7 @@ public function testBuildWhenNoStreamConfiguredThenUsesStderrFallback(): void
public function testRedactWhenNestedAndScalarAtSameLevelThenMasksBoth(): void
{
/** @Given a structured logger with document redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'multi-level-service')
->withRedactions(DocumentRedaction::default())
@@ -648,8 +650,8 @@ public function testRedactWhenNestedAndScalarAtSameLevelThenMasksBoth(): void
/** @Then both the nested and scalar documents should be redacted */
$output = $this->logStream->contents();
- self::assertStringContainsString('********100', $output);
- self::assertStringContainsString('********900', $output);
+ self::assertStringContainsString('*********00', $output);
+ self::assertStringContainsString('*********00', $output);
self::assertStringNotContainsString('11111111100', $output);
self::assertStringNotContainsString('99999999900', $output);
}
@@ -657,10 +659,10 @@ public function testRedactWhenNestedAndScalarAtSameLevelThenMasksBoth(): void
public function testRedactWhenCustomMaskLengthThenMaskMatchesThatWidth(): void
{
/** @Given a structured logger with password redaction configured with a custom fixed mask length */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'auth-service')
- ->withRedactions(PasswordRedaction::from(fields: ['password'], fixedMaskLength: 12))
+ ->withRedactions(SecretRedaction::from(fields: ['password'], fixedMaskLength: 12))
->build();
/** @When logging with a password field */
@@ -676,13 +678,13 @@ public function testRedactWhenCustomMaskLengthThenMaskMatchesThatWidth(): void
public function testLogWhenContextDerivedThenOriginalOmitsCorrelationId(): void
{
/** @Given a structured logger */
- $original = StructuredLogger::create()
+ $original = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'auth-service')
->build();
/** @And a contextual logger derived from it */
- $contextual = $original->withContext(context: LogContext::from(correlationId: 'ctx-999'));
+ $correlated = $original->withCorrelation(correlation: Correlation::from(correlationId: 'ctx-999'));
/** @When logging from the original instance */
$original->info(message: 'auth.check');
@@ -691,13 +693,13 @@ public function testLogWhenContextDerivedThenOriginalOmitsCorrelationId(): void
self::assertStringNotContainsString('correlation_id=ctx-999', $this->logStream->contents());
/** @And deriving a context yields a new instance, leaving the original untouched */
- self::assertNotSame($original, $contextual);
+ self::assertNotSame($original, $correlated);
}
public function testRedactWhenEmailHasNoAtSignThenMasksByCharacterCount(): void
{
/** @Given a structured logger with email redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(EmailRedaction::default())
@@ -716,7 +718,7 @@ public function testRedactWhenEmailHasNoAtSignThenMasksByCharacterCount(): void
public function testRedactWhenNameIsMultibyteThenPrefixCountsCharacters(): void
{
/** @Given a structured logger with name redaction and a multibyte value */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'profile-service')
->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 2))
@@ -732,9 +734,9 @@ public function testRedactWhenNameIsMultibyteThenPrefixCountsCharacters(): void
public function testLogWhenContextBoundAtCreationThenWritesCorrelationId(): void
{
/** @Given a structured logger created with a correlation context */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
- ->withContext(context: LogContext::from(correlationId: 'req-initial'))
+ ->withCorrelation(correlation: Correlation::from(correlationId: 'req-initial'))
->withComponent(component: 'order-service')
->build();
@@ -748,7 +750,7 @@ public function testLogWhenContextBoundAtCreationThenWritesCorrelationId(): void
public function testLogWhenPayloadIsUnencodableThenWritesEncodingFailure(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'broken-json-service')
->build();
@@ -763,7 +765,7 @@ public function testLogWhenPayloadIsUnencodableThenWritesEncodingFailure(): void
public function testRedactWhenEmailStartsWithAtSignThenLeavesValueIntact(): void
{
/** @Given a structured logger with email redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 2))
@@ -779,7 +781,7 @@ public function testRedactWhenEmailStartsWithAtSignThenLeavesValueIntact(): void
public function testRedactWhenNestedArrayThenMasksTargetAndKeepsSiblings(): void
{
/** @Given a structured logger with document redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'nested-service')
->withRedactions(DocumentRedaction::default())
@@ -798,7 +800,7 @@ public function testRedactWhenNestedArrayThenMasksTargetAndKeepsSiblings(): void
/** @Then the document should be redacted within the nested structure */
$output = $this->logStream->contents();
- self::assertStringContainsString('********900', $output);
+ self::assertStringContainsString('*********00', $output);
self::assertStringNotContainsString('12345678900', $output);
/** @And all sibling fields in the nested array must be preserved */
@@ -812,10 +814,10 @@ public function testRedactWhenPasswordVariesInLengthThenMaskIsFixedWidth(string
{
/** @Given a password whose length varies across runs */
/** @And a structured logger with password redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'auth-service')
- ->withRedactions(PasswordRedaction::default())
+ ->withRedactions(SecretRedaction::default())
->build();
/** @When logging with the provided password */
@@ -828,7 +830,7 @@ public function testRedactWhenPasswordVariesInLengthThenMaskIsFixedWidth(string
public function testRedactWhenPhoneIsMultibyteThenSuffixCountsCharacters(): void
{
/** @Given a structured logger with phone redaction and a multibyte suffix */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'contact-service')
->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 3))
@@ -844,7 +846,7 @@ public function testRedactWhenPhoneIsMultibyteThenSuffixCountsCharacters(): void
public function testRedactWhenDeeplyNestedThenMasksTargetsAndKeepsSibling(): void
{
/** @Given a structured logger with document redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'deep-service')
->withRedactions(DocumentRedaction::default())
@@ -864,21 +866,21 @@ public function testRedactWhenDeeplyNestedThenMasksTargetsAndKeepsSibling(): voi
/** @Then the deeply nested document should be redacted */
$output = $this->logStream->contents();
- self::assertStringContainsString('********900', $output);
+ self::assertStringContainsString('*********00', $output);
self::assertStringNotContainsString('12345678900', $output);
/** @And the sibling field in the deepest level must be preserved */
self::assertStringContainsString('"label":"deep-value"', $output);
/** @And the scalar document after the sub-array must also be redacted */
- self::assertStringContainsString('********700', $output);
+ self::assertStringContainsString('*********00', $output);
self::assertStringNotContainsString('99988877700', $output);
}
public function testRedactWhenNameShorterThanVisibleThenLeavesValueIntact(): void
{
/** @Given a structured logger with name redaction configured to show 10 characters */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 10))
@@ -897,17 +899,17 @@ public function testRedactWhenNameShorterThanVisibleThenLeavesValueIntact(): voi
public function testWithContextWhenCustomTemplateThenTemplateStillApplies(): void
{
/** @Given a structured logger with a custom template */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n")
->withComponent(component: 'template-service')
->build();
/** @And a contextual logger derived from it */
- $contextual = $logger->withContext(context: LogContext::from(correlationId: 'ctx-tmpl'));
+ $correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'ctx-tmpl'));
/** @When logging through the contextual logger */
- $contextual->info(message: 'template.event');
+ $correlated->info(message: 'template.event');
/** @Then the custom template should be preserved */
$output = $this->logStream->contents();
@@ -919,16 +921,16 @@ public function testWithContextWhenCustomTemplateThenTemplateStillApplies(): voi
public function testLogWhenContextDerivedThenContextualWritesCorrelationId(): void
{
/** @Given a structured logger */
- $original = StructuredLogger::create()
+ $original = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'auth-service')
->build();
/** @And a contextual logger derived from it */
- $contextual = $original->withContext(context: LogContext::from(correlationId: 'ctx-999'));
+ $correlated = $original->withCorrelation(correlation: Correlation::from(correlationId: 'ctx-999'));
/** @When logging from the contextual instance */
- $contextual->info(message: 'auth.success');
+ $correlated->info(message: 'auth.success');
/** @Then the contextual line carries the correlation ID */
self::assertStringContainsString('correlation_id=ctx-999', $this->logStream->contents());
@@ -937,7 +939,7 @@ public function testLogWhenContextDerivedThenContextualWritesCorrelationId(): vo
public function testLogWhenDataHasSlashesAndUnicodeThenLeavesThemUnescaped(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'encoding-service')
->build();
@@ -962,7 +964,7 @@ public function testLogWhenDataHasSlashesAndUnicodeThenLeavesThemUnescaped(): vo
public function testRedactWhenNameLengthEqualsVisibleThenLeavesValueIntact(): void
{
/** @Given a structured logger with name redaction where visible length equals value length */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 3))
@@ -981,7 +983,7 @@ public function testRedactWhenNameLengthEqualsVisibleThenLeavesValueIntact(): vo
public function testRedactWhenPhoneShorterThanVisibleThenLeavesValueIntact(): void
{
/** @Given a structured logger with phone redaction configured to show 10 characters */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'notification-service')
->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 10))
@@ -1000,16 +1002,16 @@ public function testRedactWhenPhoneShorterThanVisibleThenLeavesValueIntact(): vo
public function testLogWhenContextBoundAfterCreationThenWritesCorrelationId(): void
{
/** @Given a structured logger */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'order-service')
->build();
/** @And a contextual logger derived after creation */
- $loggerWithContext = $logger->withContext(context: LogContext::from(correlationId: 'req-abc-123'));
+ $correlatedLogger = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'req-abc-123'));
/** @When logging from the contextual logger */
- $loggerWithContext->info(message: 'order.placed', context: ['orderId' => 42]);
+ $correlatedLogger->info(message: 'order.placed', context: ['orderId' => 42]);
/** @Then the output should contain the correlation ID */
self::assertStringContainsString('correlation_id=req-abc-123', $this->logStream->contents());
@@ -1018,7 +1020,7 @@ public function testLogWhenContextBoundAfterCreationThenWritesCorrelationId(): v
public function testRedactWhenDocumentIsMultibyteThenSuffixCountsCharacters(): void
{
/** @Given a structured logger with document redaction and a multibyte suffix */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'kyc-service')
->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 3))
@@ -1034,7 +1036,7 @@ public function testRedactWhenDocumentIsMultibyteThenSuffixCountsCharacters(): v
public function testRedactWhenPhoneLengthEqualsVisibleThenLeavesValueIntact(): void
{
/** @Given a structured logger with phone redaction where visible length equals value length */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'notification-service')
->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 4))
@@ -1053,7 +1055,7 @@ public function testRedactWhenPhoneLengthEqualsVisibleThenLeavesValueIntact(): v
public function testRedactWhenDocumentShorterThanVisibleThenLeavesValueIntact(): void
{
/** @Given a structured logger with document redaction configured to show 10 characters */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'kyc-service')
->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 10))
@@ -1072,7 +1074,7 @@ public function testRedactWhenDocumentShorterThanVisibleThenLeavesValueIntact():
public function testRedactWhenDocumentLengthEqualsVisibleThenLeavesValueIntact(): void
{
/** @Given a structured logger with document redaction where visible length equals value length */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'kyc-service')
->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 3))
@@ -1091,7 +1093,7 @@ public function testRedactWhenDocumentLengthEqualsVisibleThenLeavesValueIntact()
public function testWithContextWhenRedactionConfiguredThenRedactionStillApplies(): void
{
/** @Given a structured logger with a redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'secure-service')
->withRedactions(
@@ -1103,10 +1105,10 @@ public function testWithContextWhenRedactionConfiguredThenRedactionStillApplies(
->build();
/** @And a contextual logger derived from it */
- $contextual = $logger->withContext(context: LogContext::from(correlationId: 'ctx-preserve'));
+ $correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'ctx-preserve'));
/** @When logging through the contextual logger */
- $contextual->error(message: 'secure.action', context: ['secret' => 'my-secret-value']);
+ $correlated->error(message: 'secure.action', context: ['secret' => 'my-secret-value']);
/** @Then the redaction should still be applied */
$output = $this->logStream->contents();
@@ -1118,7 +1120,7 @@ public function testWithContextWhenRedactionConfiguredThenRedactionStillApplies(
public function testRedactWhenScalarsFollowNestedArrayThenMasksAllAndKeepsStatus(): void
{
/** @Given a structured logger with redaction for two fields */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'batch-service')
->withRedactions(DocumentRedaction::from(fields: ['document', 'taxId'], visibleSuffixLength: 3))
@@ -1146,12 +1148,12 @@ public function testRedactWhenScalarsFollowNestedArrayThenMasksAllAndKeepsStatus
public function testRedactWhenDeeplyNestedAcrossLevelsThenMasksAllSensitiveFields(): void
{
- /** @Given a StructuredLogger configured with default redactions for password, email, document, phone, and name */
- $logger = StructuredLogger::create()
+ /** @Given a StreamLogger configured with redactions for password, email, document, phone, and name */
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'test')
->withRedactions(
- PasswordRedaction::default(),
+ GenericRedaction::masking(mask: Mask::fixed(), fields: ['password']),
EmailRedaction::default(),
DocumentRedaction::default(),
PhoneRedaction::default(),
@@ -1216,7 +1218,7 @@ public function testRedactWhenDeeplyNestedAcrossLevelsThenMasksAllSensitiveField
self::assertStringContainsString('@example.com', $decoded['customer']['email']);
self::assertStringNotContainsString('maria.silva@example.com', $decoded['customer']['email']);
- self::assertStringEndsWith('900', $decoded['customer']['document']);
+ self::assertStringEndsWith('00', $decoded['customer']['document']);
self::assertStringContainsString('*', $decoded['customer']['document']);
self::assertStringNotContainsString('12345678900', $decoded['customer']['document']);
@@ -1277,7 +1279,7 @@ public function testRedactWhenDeeplyNestedAcrossLevelsThenMasksAllSensitiveField
public function testRedactWhenEmailLocalPartIsMultibyteThenPrefixCountsCharacters(): void
{
/** @Given a structured logger with email redaction */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 2))
@@ -1293,7 +1295,7 @@ public function testRedactWhenEmailLocalPartIsMultibyteThenPrefixCountsCharacter
public function testRedactWhenEmailLocalPartShorterThanVisibleThenLeavesValueIntact(): void
{
/** @Given a structured logger with email redaction configured to show 10 characters */
- $logger = StructuredLogger::create()
+ $logger = StreamLogger::builder()
->withStream(stream: $this->logStream->handle())
->withComponent(component: 'user-service')
->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 10))
diff --git a/tests/Unit/TelemetryLoggerTest.php b/tests/Unit/TelemetryLoggerTest.php
new file mode 100644
index 0000000..22874ac
--- /dev/null
+++ b/tests/Unit/TelemetryLoggerTest.php
@@ -0,0 +1,382 @@
+logStream = InMemoryStream::create();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->logStream->close();
+ }
+
+ public function testMetricWhenWrittenThenTheLineIsTheRecordAlone(): void
+ {
+ /** @Given a telemetry logger writing to a stream */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'schedule')
+ ->build();
+
+ /** @When writing a measurement */
+ $logger->metric(metric: Metric::of(name: 'AppointmentNoShow', namespace: 'Acme/Schedule'));
+
+ /** @Then the line is a JSON object and nothing else, which is what the agent parses */
+ self::assertSame(
+ 1,
+ json_decode(trim($this->logStream->contents()), true)['AppointmentNoShow']
+ );
+ }
+
+ public function testLogWhenLevelIsUnknownThenThrowsUnknownLogLevel(): void
+ {
+ /** @Given a telemetry logger */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->build();
+
+ /** @Then logging at an unsupported level raises an unknown log level error */
+ $this->expectException(UnknownLogLevel::class);
+
+ /** @When logging at a level outside the supported set */
+ $logger->log('not-a-level', 'some.key');
+ }
+
+ public function testMetricWhenComponentGivenThenTheLineCarriesItAsAField(): void
+ {
+ /** @Given a telemetry logger that knows which component it speaks for */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment')
+ ->build();
+
+ /** @When writing a measurement */
+ $logger->metric(metric: Metric::of(name: 'ChargeAccepted', namespace: 'Acme/Payment'));
+
+ /** @Then the component travels on the metric, so a series can be traced to who emitted it */
+ self::assertSame('payment', json_decode(trim($this->logStream->contents()), true)['component']);
+ }
+
+ public function testBuilderWhenRedactionsGivenInTwoCallsThenBothReachTheMetric(): void
+ {
+ /** @Given a telemetry logger told about one redaction */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'identity')
+ ->withRedactions(SecretRedaction::default())
+ ->withRedactions(GenericRedaction::removing(fields: ['stack_trace']))
+ ->build();
+
+ /** @And a measurement covered by both */
+ $metric = Metric::of(name: 'SignInFailed', namespace: 'Acme/Identity')
+ ->withField(name: 'password', value: 'super-secret')
+ ->withField(name: 'stack_trace', value: '#0 /var/www/html/src/SignIn.php(7)');
+
+ /** @When writing it */
+ $logger->metric(metric: $metric);
+
+ /** @Then the later list was added to the earlier one, and not put in its place */
+ $record = json_decode(trim($this->logStream->contents()), true);
+
+ self::assertSame('********', $record['password']);
+ self::assertArrayNotHasKey('stack_trace', $record);
+ }
+
+ public function testMetricWhenNotCorrelatedThenTheLineCarriesAnEmptyIdentifier(): void
+ {
+ /** @Given a telemetry logger with no correlation bound */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'schedule')
+ ->build();
+
+ /** @When writing a measurement */
+ $logger->metric(metric: Metric::of(name: 'AppointmentNoShow', namespace: 'Acme/Schedule'));
+
+ /** @Then the field is present and empty, so the shape of the record never varies */
+ self::assertSame('', json_decode(trim($this->logStream->contents()), true)['correlation_id']);
+ }
+
+ public function testWithCorrelationWhenDerivedThenTheOriginalLoggerStaysUnbound(): void
+ {
+ /** @Given a telemetry logger with no correlation bound */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'schedule')
+ ->build();
+
+ /** @And a correlated logger derived from it */
+ $correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'req-derived'));
+
+ /** @When writing a measurement through the original */
+ $logger->metric(metric: Metric::of(name: 'AppointmentCreated', namespace: 'Acme/Schedule'));
+
+ /** @Then the derived logger is another instance and the original was not bound by it */
+ self::assertNotSame($logger, $correlated);
+ self::assertSame('', json_decode(trim($this->logStream->contents()), true)['correlation_id']);
+ }
+
+ public function testMetricWhenTheLoggerIsCorrelatedThenTheLineCarriesTheIdentifier(): void
+ {
+ /** @Given a telemetry logger built around a correlation */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'schedule')
+ ->withCorrelation(correlation: Correlation::from(correlationId: 'req-abc-123'))
+ ->build();
+
+ /** @When writing a measurement */
+ $logger->metric(metric: Metric::of(name: 'AppointmentNoShow', namespace: 'Acme/Schedule'));
+
+ /** @Then the identifier travels on the metric, which is what stitches it to the entries around it */
+ self::assertSame('req-abc-123', json_decode(trim($this->logStream->contents()), true)['correlation_id']);
+ }
+
+ public function testMetricAndEntryWhenBothWrittenThenNeitherSharesALineWithTheOther(): void
+ {
+ /** @Given a telemetry logger writing to a stream */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'schedule')
+ ->build();
+
+ /** @And an entry already written through it */
+ $logger->info(message: 'appointment.created', context: ['appointment_id' => 42]);
+
+ /** @When writing a measurement after it */
+ $logger->metric(metric: Metric::of(name: 'AppointmentCreated', namespace: 'Acme/Schedule'));
+
+ /** @Then the entry keeps the log shape and the metric keeps the record shape, on two lines */
+ $lines = explode(PHP_EOL, trim($this->logStream->contents()));
+
+ self::assertCount(2, $lines);
+ self::assertStringContainsString('key=appointment.created', $lines[0]);
+ self::assertStringContainsString('data={"appointment_id":42}', $lines[0]);
+ self::assertSame(1, json_decode($lines[1], true)['AppointmentCreated']);
+ }
+
+ public function testMetricWhenAFieldIsCoveredByARedactionThenTheLineCarriesItMasked(): void
+ {
+ /** @Given a telemetry logger that masks passwords */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'identity')
+ ->withRedactions(SecretRedaction::default())
+ ->build();
+
+ /** @And a measurement carrying a field the redaction covers */
+ $metric = Metric::of(name: 'SignInFailed', namespace: 'Acme/Identity')
+ ->withField(name: 'password', value: 'super-secret');
+
+ /** @When writing it */
+ $logger->metric(metric: $metric);
+
+ /** @Then the metric line carries the masked value, as the log line would */
+ self::assertSame('********', json_decode(trim($this->logStream->contents()), true)['password']);
+ }
+
+ public function testBuilderWhenTemplateGivenThenTheEntryFollowsItAndTheMetricDoesNot(): void
+ {
+ /** @Given a telemetry logger built with a template of its own */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withTemplate(template: "%4\$s|%5\$s|%6\$s\n")
+ ->withComponent(component: 'schedule')
+ ->build();
+
+ /** @And an entry already written through it */
+ $logger->warning(message: 'appointment.late', context: ['minutes' => 15]);
+
+ /** @When writing a measurement after it */
+ $logger->metric(metric: Metric::of(name: 'AppointmentLate', namespace: 'Acme/Schedule'));
+
+ /** @Then the entry follows the template and the metric keeps the shape its format renders */
+ $lines = explode(PHP_EOL, trim($this->logStream->contents()));
+
+ self::assertSame('WARNING|appointment.late|{"minutes":15}', $lines[0]);
+ self::assertSame(1, json_decode($lines[1], true)['AppointmentLate']);
+ }
+
+ public function testMetricWhenADimensionIsNotCoveredByARedactionThenTheLineCarriesIt(): void
+ {
+ /** @Given a telemetry logger that masks a field name */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'subscription')
+ ->withRedactions(GenericRedaction::masking(mask: Mask::proportional(), fields: ['document']))
+ ->build();
+
+ /** @And a measurement broken down by a dimension no redaction covers */
+ $metric = Metric::of(name: 'SubscriptionRenewed', namespace: 'Acme/Subscription')
+ ->withDimension(name: 'subscription_type', value: 'saas');
+
+ /** @When writing it */
+ $logger->metric(metric: $metric);
+
+ /** @Then nothing is refused, and the dimension is declared and repeated at the root */
+ $record = json_decode(trim($this->logStream->contents()), true);
+
+ self::assertSame('saas', $record['subscription_type']);
+ self::assertSame([['subscription_type']], EmbeddedMetricPayload::from(payload: $record)->dimensions());
+ }
+
+ public function testMetricWhenAFieldIsRemovedByARedactionThenItLeavesTheLineEntirely(): void
+ {
+ /** @Given a telemetry logger that drops a field instead of masking it */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment')
+ ->withRedactions(GenericRedaction::removing(fields: ['stack_trace']))
+ ->build();
+
+ /** @And a measurement carrying the field that is dropped and one that is kept */
+ $metric = Metric::of(name: 'ChargeFailed', namespace: 'Acme/Payment')
+ ->withField(name: 'stack_trace', value: '#0 /var/www/html/src/Charge.php(42)')
+ ->withField(name: 'gateway', value: 'acquirer-a');
+
+ /** @When writing it */
+ $logger->metric(metric: $metric);
+
+ /** @Then the dropped field is absent from the record and the other one survives */
+ $record = json_decode(trim($this->logStream->contents()), true);
+
+ self::assertArrayNotHasKey('stack_trace', $record);
+ self::assertSame('acquirer-a', $record['gateway']);
+ }
+
+ public function testBuilderWhenDerivedThenTheEarlierBuilderStillBuildsWhatItDescribed(): void
+ {
+ /** @Given a builder describing a component */
+ $builder = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'schedule');
+
+ /** @And a copy of it describing another component */
+ $derived = $builder->withComponent(component: 'payment');
+
+ /** @When building through the earlier builder */
+ $builder->build()->metric(metric: Metric::of(name: 'AppointmentNoShow', namespace: 'Acme/Schedule'));
+
+ /** @Then the copy is another builder and the earlier one was not changed by it */
+ self::assertNotSame($builder, $derived);
+ self::assertSame('schedule', json_decode(trim($this->logStream->contents()), true)['component']);
+ }
+
+ public function testMetricWhenMeasuredInAUnitThenTheLineDeclaresItAndKeepsThePrecision(): void
+ {
+ /** @Given a telemetry logger writing to a stream */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'schedule')
+ ->build();
+
+ /** @And a measurement that is a duration and not a count */
+ $metric = Metric::of(name: 'AppointmentDuration', namespace: 'Acme/Schedule')
+ ->withUnit(unit: MetricUnit::SECONDS)
+ ->withValue(value: 12.5);
+
+ /** @When writing it */
+ $logger->metric(metric: $metric);
+
+ /** @Then the declaration carries the unit and the value keeps its precision */
+ $record = json_decode(trim($this->logStream->contents()), true);
+
+ self::assertSame(
+ [['Name' => 'AppointmentDuration', 'Unit' => 'Seconds']],
+ EmbeddedMetricPayload::from(payload: $record)->metrics()
+ );
+ self::assertSame(12.5, $record['AppointmentDuration']);
+ }
+
+ public function testMetricWhenEntriesAreSilencedByTheThresholdThenTheSeriesStillTravels(): void
+ {
+ /** @Given a telemetry logger quiet enough to discard an informational entry */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'schedule')
+ ->withMinimumLevel(minimumLevel: LogLevel::ERROR)
+ ->build();
+
+ /** @And an entry below the threshold, which is discarded */
+ $logger->info(message: 'appointment.created');
+
+ /** @When writing a measurement */
+ $logger->metric(metric: Metric::of(name: 'AppointmentCreated', namespace: 'Acme/Schedule'));
+
+ /** @Then only the metric reached the stream, because a measurement carries no severity */
+ $lines = explode(PHP_EOL, trim($this->logStream->contents()));
+
+ self::assertCount(1, $lines);
+ self::assertSame(1, json_decode($lines[0], true)['AppointmentCreated']);
+ }
+
+ public function testWithCorrelationWhenDerivedThenTheEntryAndTheMetricCarryTheIdentifier(): void
+ {
+ /** @Given a telemetry logger with no correlation bound */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'schedule')
+ ->build();
+
+ /** @And a correlated logger derived from it */
+ $correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'req-derived'));
+
+ /** @And an entry already written through the derived logger */
+ $correlated->info(message: 'appointment.created');
+
+ /** @When writing a measurement through it */
+ $correlated->metric(metric: Metric::of(name: 'AppointmentCreated', namespace: 'Acme/Schedule'));
+
+ /** @Then both records carry the identifier the derived logger was bound to */
+ $lines = explode(PHP_EOL, trim($this->logStream->contents()));
+
+ self::assertStringContainsString('correlation_id=req-derived', $lines[0]);
+ self::assertSame('req-derived', json_decode($lines[1], true)['correlation_id']);
+ }
+
+ public function testMetricWhenADimensionIsCoveredByARedactionThenRefusesAndNamesTheAlternative(): void
+ {
+ /** @Given a telemetry logger that masks a field name */
+ $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default())
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'identity')
+ ->withRedactions(GenericRedaction::masking(mask: Mask::proportional(), fields: ['document']))
+ ->build();
+
+ /** @And a measurement broken down by a dimension carrying that same name */
+ $metric = Metric::of(name: 'SignInFailed', namespace: 'Acme/Identity')
+ ->withDimension(name: 'document', value: '12345678900');
+
+ /** @Then the refusal names the dimension and points at the field */
+ $this->expectException(RedactedDimension::class);
+ $this->expectExceptionMessage(
+ 'The dimension is covered by a redaction. A dimension value reaches the metric index of '
+ . 'the backend, where no redaction and no log retention can reach it. Emit it as a field instead.'
+ );
+
+ /** @When writing it */
+ $logger->metric(metric: $metric);
+ }
+}