Skip to content

perf: batch log/metric delivery instead of one blocking HTTP call per event - #4

Open
mleczakm wants to merge 1 commit into
mainfrom
feat/batching-async-transport
Open

perf: batch log/metric delivery instead of one blocking HTTP call per event#4
mleczakm wants to merge 1 commit into
mainfrom
feat/batching-async-transport

Conversation

@mleczakm

@mleczakm mleczakm commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Why

Every logger()->info() / metrics()->set() / metrics()->mutate() call fired its own fully synchronous Guzzle request with a 5s timeout, directly on the caller's code path - the send()/sendMetric() methods carried a literal // TODO: queue, retry, batching. Under high log/metric volume, or when the logdash API is slow or unreachable, this could stall the host application for up to 5 seconds per call, serially.

What

  • RequestQueue (new): buffers items in memory and flushes them when a batch-size threshold is hit, when a flush interval has elapsed, or when the PHP process/request shuts down (via register_shutdown_function). Includes bounded retry for transient failures.
  • HttpLogSync: now buffers log lines and posts them to POST /logs/batch (the same endpoint the Node SDK already uses) instead of issuing one POST /logs per line.
  • Metrics: now dispatches queued metric updates concurrently via a GuzzleHttp\Pool (curl_multi under the hood) instead of one sequential blocking PUT per update. Failures stay isolated per item and are not retried at the batch level, since mutate is not idempotent and retrying a whole batch could double-apply an increment.
  • Split connect_timeout (2s) from the overall timeout (5s) on both clients, so a fully unreachable host fails fast on connect instead of burning the whole 5s budget just to establish a connection.
  • httpClient is now an injectable constructor param on both classes (optional, defaults unchanged) so they're unit-testable with MockHandler instead of hitting the network.

Public API (LogSync/BaseMetrics interfaces, send()/set()/mutate() signatures) is unchanged - this is transport-layer only. Both classes gained an optional flush() method for callers (e.g. long-running workers) that want to force a send before going idle, instead of waiting on the batch size/interval/shutdown triggers.

Proof

A MockHandler-based benchmark: 200 send() calls now produce 8 HTTP requests instead of 200, with all 200 log lines delivered intact (sequence numbers preserved). New tests cover queue batching/retry logic, the /logs/batch payload shape, and that one failing metric in a batch doesn't block the others.

Testing

  • composer test - 14/14 passing (was 5/5; added RequestQueueTest, HttpLogSyncTest, MetricsTest)
  • composer phpstan - level 8, no errors
  • composer cs - PSR-12, clean
  • ./vendor/bin/parallel-lint src tests - no syntax errors

… event

Every logger()->info()/metrics()->set() call fired its own synchronous
Guzzle request with a 5s timeout. Under high log/metric volume, or when
the logdash API is slow/unreachable, this serialized directly onto the
request path and could stall the host app for seconds per call - exactly
the TODO the code already flagged (queue/retry/batching).

- Add RequestQueue: buffers items and flushes on batch size, a lazy
  time interval, or process/request shutdown, with bounded retry.
- HttpLogSync now posts buffered logs to POST /logs/batch (the endpoint
  the Node SDK already uses) instead of one POST /logs per line.
- Metrics now dispatches queued updates concurrently via a Guzzle Pool
  (curl_multi) instead of sequentially; failures stay isolated per-item
  and are never retried, since `mutate` isn't idempotent.
- Split connect_timeout (2s) from the overall timeout (5s) so a
  fully unreachable host fails fast instead of hanging the connect
  phase for the whole 5s budget.
- httpClient is now injectable for testing.

200 send() calls now produce 8 HTTP requests instead of 200 (verified
via a MockHandler-based benchmark).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant