Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ ifeq ($(ARCH),arm64)
endif

TTY := $(shell [ -t 0 ] && echo -it)
HOST_USER := $(shell id -u):$(shell id -g)

PHP_VERSION := $(shell sed -n 's/.*"php": *"^\([0-9]*\.[0-9]*\)".*/\1/p' composer.json)
IMAGE_VERSION := 1.0.0
PHP_IMAGE := gustavofreze/php:${PHP_VERSION}-cli-${IMAGE_VERSION}
WORKSPACE := /var/www/html

DOCKER_RUN = docker run ${PLATFORM} --rm ${TTY} --net=host -v ${PWD}:${WORKSPACE} ${PHP_IMAGE}
# The runner drops to the calling user, as the twelve service Makefiles and the CLI already do. A
# root runner writes `vendor/`, `reports/` and the PHPStan cache into the bind mount owned by root,
# and then only the owner can remove them: measured at 801 root owned paths from a single
# `make review` in this repository. COMPOSER_HOME moves off /root because that path belongs to root
# inside the image and a uid with no passwd entry cannot write it.
DOCKER_RUN = docker run ${PLATFORM} -u ${HOST_USER} --rm ${TTY} --net=host \
-e COMPOSER_HOME=/tmp/composer \
-v ${PWD}:${WORKSPACE} ${PHP_IMAGE}

RESET := \033[0m
GREEN := \033[0;32m
Expand Down Expand Up @@ -55,7 +63,6 @@ show-image: ## Show the pinned PHP tooling image

.PHONY: clean
clean: ## Remove dependencies and generated artifacts
@sudo chown -R ${USER}:${USER} ${PWD}
@rm -rf reports vendor .phpunit.cache *.lock

.PHONY: help
Expand Down
800 changes: 572 additions & 228 deletions README.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
{
"name": "tiny-blocks/logger",
"description": "Emits PSR-3 structured logs for PHP, with correlation tracking, a severity threshold, and configurable redaction.",
"description": "Emits PSR-3 structured logs for PHP, with correlation tracking, a severity threshold, configurable redaction, and backend-neutral metrics.",
"license": "MIT",
"type": "library",
"keywords": [
"emf",
"psr-3",
"logger",
"metrics",
"logging",
"redaction",
"cloudwatch",
"tiny-blocks"
],
"authors": [
Expand Down
79 changes: 63 additions & 16 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ parameters:
# cannot annotate the value type, and PHPDoc is prohibited on Internal types and constructors.
- identifier: missingType.iterableValue
path: src/Internal/LogFormatter.php
- identifier: missingType.iterableValue
path: src/Internal/EncodedPayload.php
- identifier: missingType.iterableValue
path: src/Internal/LogEntryRecorder.php
- identifier: missingType.iterableValue
Expand All @@ -21,8 +23,15 @@ parameters:
path: src/LogEntry.php
- identifier: missingType.iterableValue
path: src/Redactions/*.php
# The builders describe the redactions as a plain list of Redaction, and the loggers hand that
# list to the typed collection when they build. PHPDoc is prohibited on constructors, and the
# element type cannot be written on a promoted property, so it is lost at both crossings.
- identifier: missingType.iterableValue
path: src/StructuredLoggerBuilder.php
path: src/StreamLoggerBuilder.php
- identifier: argument.type
path: src/StreamLogger.php
- identifier: argument.type
path: src/TelemetryLogger.php
# The redactors traverse plain arrays into the array<string, mixed> contract of
# Redaction::redact, and the field matcher reads plain string patterns out of an untyped
# configuration array; the values originate as mixed log context, an irreducible boundary
Expand All @@ -36,33 +45,71 @@ parameters:
path: src/Internal/LogEntryRecorder.php
- identifier: argument.type
path: src/Redactions/*.php
# The field matcher indexes its exact field names by value to answer in constant time. Those
# names come from the same untyped configuration array, so the language cannot prove the key
# type at that point.
- identifier: offsetAccess.invalidOffset
# The field matcher reads its field names out of the same untyped configuration array and casts
# each one to string before comparing, so the language cannot prove the type at that point.
- identifier: cast.string
path: src/Internal/Redactor/FieldMatcher.php
# LogStream wraps a stream resource the language can only promote as mixed; fwrite then
# receives that mixed handle. Internal collaborator with intrinsic resource state.
- identifier: argument.type
path: src/Internal/Stream/LogStream.php
# The builder accumulates Redaction instances in a variadic pass-through array and spreads
# them into StructuredLogger::from(); the element type cannot be annotated on the promoted
# constructor parameter without prohibited PHPDoc.
# The metric carries its dimensions and fields as plain arrays, and the Internal collaborators
# that render them read those shapes back. PHPDoc is prohibited on constructors and on Internal
# types, and neither shape is generic, so the value type cannot be written anywhere.
- identifier: missingType.iterableValue
path: src/Metrics/Metric.php
- identifier: missingType.iterableValue
path: src/Internal/Metrics/*.php
# UnboundedDimensions holds the refused names in a promoted array and looks each one up among
# the dimension keys. The names reach it as strings through a variadic, but the element type is
# lost on the promoted parameter, which is the same boundary the metric line crosses.
- identifier: argument.type
path: src/StructuredLoggerBuilder.php
# json_decode() returns mixed, so the deep recursive redaction test array-accesses a mixed
# value when reading the decoded payload. Descriptive PHPDoc is prohibited in tests/, so the
# irreducible mixed-origin offset access is suppressed for this file only.
path: src/Internal/Metrics/*.php
# UnboundedDimensions hands back one of the names it holds, which reached it as a string through a
# variadic. The promoted array lost the element type, and a cast to restore it would be a line no
# test can kill, since every name in there is already a string.
- identifier: return.type
path: src/Internal/Metrics/*.php
# MetricLine reads the Field values back out of the plain array the metric keeps them in, and
# feeds them to the redactions and back. The element type is gone on both crossings.
- identifier: property.nonObject
path: src/Internal/Metrics/*.php
# EmbeddedMetricFormat::format() hands back what the envelope built. MetricFormat declares the
# payload shape and the interface docblock is where it belongs; the value type is lost crossing
# the Internal collaborator, which cannot carry PHPDoc to restore it.
- identifier: return.type
path: src/Metrics/CloudWatch/*.php
# The metric data providers return rows of arguments and the record fixture navigates a payload
# whose nested shape the language cannot see. PHPDoc is prohibited anywhere inside tests/.
- identifier: missingType.iterableValue
path: tests/Unit/EmbeddedMetricFormatTest.php
- identifier: missingType.iterableValue
path: tests/Unit/MetricTest.php
# The test reads a Dimension and a Field back out of the plain arrays the metric keeps them in,
# so the element type is gone by the time the assertion reaches the value object.
- identifier: property.nonObject
path: tests/Unit/MetricTest.php
- identifier: missingType.iterableValue
path: tests/Models/EmbeddedMetricPayload.php
- identifier: offsetAccess.nonOffsetAccessible
path: tests/StructuredLoggerTest.php
path: tests/Models/EmbeddedMetricPayload.php
# json_decode() returns mixed, so the deep recursive redaction test and the metric line tests
# array-access a mixed value when reading the decoded payload. Descriptive PHPDoc is prohibited
# in tests/, so the irreducible mixed-origin offset access is suppressed for those files.
- identifier: offsetAccess.nonOffsetAccessible
path: tests/Unit/StreamLoggerTest.php
- identifier: offsetAccess.nonOffsetAccessible
path: tests/Unit/TelemetryLoggerTest.php
# The mixed values read from that decoded payload feed string-typed PHPUnit assertion
# parameters (assertStringStartsWith, assertStringContainsString, ...). Same mixed origin.
- identifier: argument.type
path: tests/StructuredLoggerTest.php
path: tests/Unit/StreamLoggerTest.php
- identifier: argument.type
path: tests/Unit/TelemetryLoggerTest.php
# The data providers return lists of argument rows. Their iterable value type cannot be
# expressed without PHPDoc, which tests/ forbids, so it is suppressed for those files.
- identifier: missingType.iterableValue
path: tests/LogLevelTest.php
path: tests/Unit/LogLevelTest.php
- identifier: missingType.iterableValue
path: tests/StructuredLoggerTest.php
path: tests/Unit/StreamLoggerTest.php
reportUnmatchedIgnoredErrors: true
10 changes: 5 additions & 5 deletions src/LogContext.php → src/Correlation.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,20 @@
/**
* Correlation context carried across log entries.
*/
final readonly class LogContext
final readonly class Correlation
{
private function __construct(public string $correlationId)
{
}

/**
* Creates a LogContext from a correlation identifier.
* Creates a Correlation from a correlation identifier.
*
* @param string $correlationId The correlation identifier shared across related log entries.
* @return LogContext The created instance.
* @return Correlation The created instance.
*/
public static function from(string $correlationId): LogContext
public static function from(string $correlationId): Correlation
{
return new LogContext(correlationId: $correlationId);
return new Correlation(correlationId: $correlationId);
}
}
17 changes: 17 additions & 0 deletions src/Exceptions/BlankMetricIdentifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Logger\Exceptions;

use InvalidArgumentException;

final class BlankMetricIdentifier extends InvalidArgumentException
{
public function __construct(private readonly string $identifier)
{
parent::__construct(
message: sprintf('The metric %s cannot be blank.', $this->identifier)
);
}
}
21 changes: 21 additions & 0 deletions src/Exceptions/DuplicateMetricIdentifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Logger\Exceptions;

use InvalidArgumentException;

final class DuplicateMetricIdentifier extends InvalidArgumentException
{
public function __construct(private readonly string $identifier)
{
parent::__construct(
message: sprintf(
'The name <%s> 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.',
$this->identifier
)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
/**
* Raised when a redaction is configured with a pattern the regular expression engine rejects.
*/
final class InvalidRedactionPattern extends InvalidArgumentException
final class MalformedRedactionPattern extends InvalidArgumentException
{
}
21 changes: 21 additions & 0 deletions src/Exceptions/RedactedDimension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Logger\Exceptions;

use InvalidArgumentException;

final class RedactedDimension extends InvalidArgumentException
{
public function __construct(private readonly string $dimension)
{
parent::__construct(
message: sprintf(
'The dimension <%s> 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.',
$this->dimension
)
);
}
}
20 changes: 20 additions & 0 deletions src/Exceptions/UnboundedDimension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Logger\Exceptions;

use InvalidArgumentException;

final class UnboundedDimension extends InvalidArgumentException
{
public function __construct(private readonly string $dimension)
{
parent::__construct(
message: sprintf(
'The dimension <%s> was declared unbounded. Emit it as a field of the same record instead.',
$this->dimension
)
);
}
}
18 changes: 9 additions & 9 deletions src/InMemoryLogger.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,25 @@
* what was logged rather than how it was rendered. Payloads are recorded exactly as received, with
* no redaction and no formatting, so assertions read the original values.</p>
*
* <p>Loggers derived through {@see withContext} record into the same store as the instance they
* <p>Loggers derived through {@see withCorrelation} record into the same store as the instance they
* come from, so entries logged through a derived instance are visible from either one.</p>
*/
final readonly class InMemoryLogger implements Logger
{
use LoggerTrait;

private function __construct(private ?LogContext $context, private LogEntryRecorder $recorder)
private function __construct(private LogEntryRecorder $recorder, private ?Correlation $correlation)
{
}

/**
* Creates an InMemoryLogger with no bound context and no recorded entries.
* Creates an InMemoryLogger with no bound correlation and no recorded entries.
*
* @return InMemoryLogger The created instance.
*/
public static function create(): InMemoryLogger
{
return new InMemoryLogger(context: null, recorder: new LogEntryRecorder());
return new InMemoryLogger(recorder: new LogEntryRecorder(), correlation: null);
}

/**
Expand All @@ -48,11 +48,11 @@ public static function create(): InMemoryLogger
public function log(mixed $level, string|Stringable $message, array $context = []): void
{
$this->recorder->record(
entry: LogEntry::from(
entry: LogEntry::of(
key: (string)$message,
level: LogLevel::fromPsrLevel(level: $level),
context: $this->context,
payload: $context
payload: $context,
correlation: $this->correlation
)
);
}
Expand All @@ -67,8 +67,8 @@ public function entries(): LogEntries
return $this->recorder->toLogEntries();
}

public function withContext(LogContext $context): InMemoryLogger
public function withCorrelation(Correlation $correlation): InMemoryLogger
{
return new InMemoryLogger(context: $context, recorder: $this->recorder);
return new InMemoryLogger(recorder: $this->recorder, correlation: $correlation);
}
}
33 changes: 33 additions & 0 deletions src/Internal/EncodedPayload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Logger\Internal;

use JsonException;

final readonly class EncodedPayload
{
private const string ENCODING_FAILURE = '{"error":"encoding_failed"}';

private function __construct(private array $payload)
{
}

public static function from(array $payload): EncodedPayload
{
return new EncodedPayload(payload: $payload);
}

public function toString(): string
{
try {
return json_encode(
$this->payload,
(JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)
);
} catch (JsonException) {
return self::ENCODING_FAILURE;
}
}
}
Loading