sixty

server agent

PHP

The only agent that has to work without a background thread. A PHP worker cannot run a timer and everything it learned dies with the request, so windows are pooled in shared memory and merged — losslessly, or the percentiles would be fiction.

package
sixty-sh/sixty on Packagist
runs on
PHP 8.1 or later. Laravel 10–12, Symfony 6.4/7, Doctrine DBAL 3 and 4.
source
packages/php

Installing it

Laravel and Symfony install themselves; anything on PDO or MongoDB takes two lines. Postgres, MySQL and MongoDB, with query plans on Postgres.

The install is written as a prompt for the coding agent you already have open, not as a checklist for you. That is deliberate: it names what must be true when the install is finished rather than which files to edit, because where the code goes depends on the framework and putting it in the wrong place fails silently. An agent can read your repository and work that out; a paragraph on a documentation page cannot.

The same text is what install_sixty returns through the MCP server and what the collector serves at /v1/setup?kind=php. There is one copy of it.

the PHP install, in full
Install the sixty agent in this PHP application so its controllers, queries
and the code between them report to sixty.

1. Add the package to the main require block of composer.json — not to
   require-dev. It runs in production; that is the entire point. Its only
   requirements are ext-json and ext-curl.

      composer require sixty-sh/sixty

2. Wire it up, which depends on the framework:

   a. Laravel — nothing to do. The service provider is discovered, and it
      registers the request span, the query instrumentation and the flush.
      Do NOT publish a config file or call Sixty::init() from a provider;
      that is a second boot path for the same thing.

   b. Symfony — add the bundle to config/bundles.php. It does the same three
      things, including the Doctrine DBAL middleware.

   c. Anything else — call \Sixty\Sixty::init() once at startup, then
      \Sixty\Instrument\Pdo::instrument($pdo) on the connection the
      application already has.

3. Measure the layer between the controller and the database. PHP has no
   build step to rewrite functions and no hook that fires when a method is
   defined, so this step is a line of code and it is the step the install
   exists for — without it the feed has routes and SQL and nothing in
   between, and an N+1 can be seen but not attributed to what causes it.

      return \Sixty\Sixty::trace('OrdersQuery#forUser', fn () => ...);

   The name is an identity compared across releases, so it must not contain
   anything that varies per call. An id in a name mints an operation per id.

4. Set these environment variables wherever the application is deployed:
      SIXTY_API_KEY  = a secret key starting sixty_sk_ — ask me for it. Do not
                       invent one, and do not commit it.
      SIXTY_SERVICE  = my-app
      SIXTY_ENDPOINT = https://ingest.sixty.sh

   The release identifier is picked up automatically on Vercel, Render,
   Railway, Fly, Heroku and GitHub Actions. If this deploys some other way,
   set SIXTY_RELEASE to the commit SHA — without one, every measurement lands
   in a single nameless bucket and no comparison can ever be made.

Constraints — correctness requirements, not style preferences:

- Do NOT change any application behaviour. This is instrumentation only: no
  refactors, no reordering of business logic, no "while I was in here" fixes.
- Do NOT write a PDO subclass or replace the application's connection. A PDO
  subclass has to open its own connection, which doubles every deployment's
  connection count silently and only fails under load. The agent sets
  PDO::ATTR_STATEMENT_CLASS on the connection that already exists; use that,
  or TracedPdo for a connection you construct yourself.
- Do NOT add any analytics, user id, session id, or cookie to what is
  reported. The agent is deliberately anonymous and must stay that way.
- If this application runs under Swoole coroutines, STOP and tell me. The
  agent refuses to enable there on purpose — requests share a worker and
  switch at every I/O boundary, so spans would be credited to the wrong
  request — and that refusal must not be worked around. FPM, CLI, RoadRunner
  and FrankenPHP are process-per-request and fully supported.

If ext-apcu is not installed, note that: with it, one request per interval
merges every worker's window and sends one payload, and without it each
request sends its own. Both are correct; the first is far less traffic.

When you are done, tell me which files you changed, so I can confirm data is
arriving.

It needs a secret key — it starts sixty_sk_ and stays server-side. Mint one on the Settings page once you have signed in.

What it measures

signalunitwhat it means
rowsrows per callthis query returns more rows than it used to
fanoutqueries per callthis operation now issues more database calls per invocation — an N+1
latencyms per callthis operation takes longer end to end than it used to
self_latencyms per callthe time spent in this function itself got longer — its children did not
payloadbytes per callthe serialized result of this operation got bigger
errorserror ratea larger fraction of calls are throwing
runawaycalls per minutethis operation is being called far more often than anything triggers it
repeated_querytimes per requestthe identical query runs several times within one request
overfetchrows per callfar more rows are fetched than the code appears to use
unboundedrows per callthis query has no upper bound on what it can return
recursionlevels deepthis operation calls itself, deeper than it should
new_erroroccurrencesan error that did not occur in the previous release
missing_tenancyThis reads a table of per-person data without saying whose rows it wants. Unless your database is filtering it for you, everyone gets everyone else's.
collapseThis is handing back roughly half the data it used to, or less. If that was not deliberate, something is filtering out rows that somebody expects to see.
vanishedIt was being used steadily until this release and has not been used once since. Usually the link, button, or redirect that led here stopped working.
traffic_dropThis is still being used, but a fraction as often, and its share of your traffic fell too — so it is not just a quiet period.
round_tripsround trips per readone read now waits on the database many times instead of once
planthe database chose a different plan for this query

Where it hooks in

  • LaravelAutomatic. The service provider is discovered and wires the request span, the queries and the flush.
  • SymfonyAdd the bundle to config/bundles.php. Same three things, including the Doctrine DBAL middleware.
  • Anything elseSixty::init(), then Pdo::instrument($pdo) on the connection the application already has.
  • Your own codeSixty::trace('OrdersQuery#forUser', fn () => …). PHP has no build step to rewrite functions and no hook that fires when a method is defined, so this is a line rather than a decorator.

Databases

  • PDOInstrumented without replacing your connection: a PDO subclass would have to open its own, silently doubling every deployment’s connection count. The statement class is set on the connection that already exists.
  • Laravel, any driverPicked up at ConnectionEstablished. Rows from rowCount().
  • Doctrine DBALA middleware registered by the bundle. Rows from the result’s own count.
  • MongoDBIncluding Doctrine ODM, via the driver’s command monitoring. Identity is built from keys only, so no value has a path into it.

What only this one does

  • A flush that runs after the responsefastcgi_finish_request() first, then the merge and the POST. A collector that is down and takes the full timeout costs the reader nothing, because they already have their page.
  • Lossless merging across workersWith ext-apcu each request writes its own window and one request per interval merges them all into a single payload. Sketches union exactly, so the percentiles are what a single process measuring everything would have reported.

What it cannot do

  • It refuses to enable under Swoole coroutines, and says why. Many requests share one worker there and switch at every I/O boundary, so the current span would credit one request’s queries to another request’s controller. FPM, CLI, RoadRunner and FrankenPHP are process-per-request and fully supported.
  • Without ext-apcu each request sends its own window. It still works, and the agent says so once at startup rather than pretending otherwise — but it is a great deal more traffic.
  • PDO::query() and PDO::exec() never reach a statement class, so a connection you construct yourself and call those on needs TracedPdo. prepare() + execute() — every query Laravel and Doctrine issue — is covered.
  • If something else already owns the statement class — a profiler, a debug bar — the agent leaves it alone and measures nothing rather than breaking it.
  • CPU and waiting are not split.

Configuration

Every agent reads the same four variables, and DRIFT_* still answers everywhere SIXTY_* does — the product was renamed and that name is not ours to retire from other people’s deployments.

SIXTY_API_KEYWithout it the agent stays inert and says so. It never guesses, never retries against an unknown endpoint, and never throws.
SIXTY_SERVICEWhat to call this service. Defaults to the project name where one is legible.
SIXTY_RELEASEThe one that matters most. Picked up automatically on Vercel, Render, Railway, Fly, Heroku and GitHub Actions; set it to the commit SHA anywhere else. Without it every measurement lands in a single nameless bucket and no comparison can ever be made.
SIXTY_ENDPOINTWhere to report. Defaults to http://localhost:4319, which is right on a laptop and wrong the moment the app is served to anyone else.

The rest — flush interval, sample rate, what to instrument — is in the package’s own README, which is where it can stay true as the agent changes.

The sixty PHP agent — what it measures and how to install it