Skip to content
Open
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
19 changes: 10 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,21 @@ Branches: `2.x` (the default) is the next major and may break BC; `1.x` is the 1

The core abstraction is `ClientContextInterface::getClient(): Client`. Controllers obtain the current `Setono\Client\Client` either by autowiring `ClientContextInterface`, or — more commonly — by type-hinting `Setono\Client\Client` directly on a controller argument, which `Controller/ClientValueResolver` resolves via the context.

### Decorator chains (the key structural idea)
### Service wiring (decoration)

Three concerns are each built as a **Symfony service-decoration chain** in `config/services.xml`. Higher `decoration-priority` is applied first and ends up *innermost*; the public alias resolves to the outermost decorator. Understanding the resolution order is essential before changing any service wiring:
Two concerns are built with **Symfony service decoration** in `config/services.xml`. Higher `decoration-priority` is applied first and ends up *innermost*; the decorated id resolves to the outermost decorator. Understanding the resolution order is essential before changing any service wiring:

- **Client context** — runtime order outermost→innermost: `CachedClientContext` (prio 32) → `CookieBasedClientContext` (prio 64) → `DefaultClientContext`.
- `Cached` memoizes the `Client` for the request. It implements `ResetInterface` and is tagged `kernel.reset`, so long-running runtimes (FrankenPHP worker mode, RoadRunner) don't hand one visitor's client to the next request; `tests/Functional/LongRunningProcessTest.php` covers this. Any new service that holds per-request state needs the same, unless it keys the state by the request object like `CachedCookieProvider`.
- `CookieBased` reads the cookie; if present, builds `Client(cookieId, metadataProvider->getMetadata(...))`; otherwise delegates.
- `Default` returns an anonymous `new Client(null, new ChangeAwareMetadata())`.
- **Cookie provider** — `CachedCookieProvider` (prio 32, memoizes per `Request` in a `WeakMap` keyed by the request object, so it needs no reset; not `spl_object_hash()`, which PHP hands to a new request once the old one is freed) → `RequestBasedCookieProvider` (parses the cookie off the request). The test kernel keeps its requests alive, so only `CachedCookieProviderTest` can reproduce that handle reuse.
- **Metadata provider** — `DoctrineOrmBasedMetadataProvider` (prio 64) → `EmptyMetadataProvider` (default). Doctrine loads from DB, falling back to the decorated provider when no row exists.
- **Client context**: runtime order outermost→innermost: `CachedClientContext` → the application's decorators → `ClientContext` (`setono_client.client_context.default`). The current client is the source of truth for the client id; see `docs/adr/0001-the-current-client-is-the-source-of-truth-for-the-client-id.md`.
- `CachedClientContext` decorates the **interface alias** `ClientContextInterface` at priority `-1024`, so it wraps every application decorator, whether that decorates the interface (e.g. `#[AsDecorator(ClientContextInterface::class)]`) or `setono_client.client_context.default`. It gives a request one client, even when a decorator creates a client on every call (`tests/Functional/DecoratedClientContextTest.php` uses such a decorator, `tests/Functional/App/Context/RequestedClientContext.php`). It keeps the clients in a `WeakMap` keyed by the main `Request` object, so it needs no `kernel.reset` in long-running runtimes (FrankenPHP worker mode, RoadRunner; `tests/Functional/LongRunningProcessTest.php`). Don't key by `spl_object_hash()`: PHP hands a freed request's handle to the next request. Without a request it passes every call on. Any new service that holds per-request state should key it by the request object the same way.
- `ClientContext` reads the client cookie of the main request with `Cookie/ClientCookieJar`. A usable cookie gives `Client(cookieId, metadataProvider->getMetadata(...))`; otherwise it's a new client, `new Client(null, new ChangeAwareMetadata())`. Without a request, every call gets an anonymous client that is never stored.
- The bundle's own consumers (`StoreCookieSubscriber`, `StoreMetadataSubscriber`, `ClientValueResolver`) inject the interface alias, never `setono_client.client_context.default`, so they get the memo and the application's decorators.
- **Metadata provider**: `DoctrineOrmBasedMetadataProvider` (prio 64) → `EmptyMetadataProvider` (default). Doctrine loads from DB, falling back to the decorated provider when no row exists.

`Cookie/ClientCookieJar` (`@internal`) owns everything about the client cookie: its name, format, validation (a non-empty client id of at most 255 bytes) and expiry. `read()` returns null for a missing or unusable cookie. `write()` stores the current client's id, keeps `firstSeenAt` only when the request's cookie holds the same id (otherwise fresh timestamps), and throws a `LogicException` for a client id that `read()` would reject.

### Request lifecycle

- `EventSubscriber/StoreCookieSubscriber` (on `kernel.response`): dispatches `Event/PreStoreCookieEvent` (an app can set `$store = false` to veto), then writes/refreshes the cookie with an updated `lastSeenAt`. The cookie is set `HttpOnly(false)` on purpose so client-side JS can read it.
- `EventSubscriber/StoreCookieSubscriber` (on `kernel.response`): dispatches `Event/PreStoreCookieEvent` (an app can set `$store = false` to veto), then writes the client cookie of the current client with `ClientCookieJar::write()`. The cookie is set `HttpOnly(false)` on purpose so client-side JS can read it.
- `EventSubscriber/StoreMetadataSubscriber` (on `kernel.finish_request`): calls the metadata persister for the current client.

### Lazy metadata (why writes are cheap)
Expand Down
39 changes: 39 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Client tracking

Recognizes the same browser across visits and keeps key/value data about it between visits.

## Language

**Client**:
A browser the application recognizes across visits, identified by its client id.
_Avoid_: user, customer, session

**Client id**:
The identifier of a client. It's opaque to the application and the same on every visit.
_Avoid_: visitor id, tracking id

**Current client**:
The client the request being handled belongs to. It's the source of truth for the client id, and the client cookie always follows it.
_Avoid_: resolved client, active client

**New client**:
A client that the request introduced: the request had no valid client cookie, so it got a new client id.
_Avoid_: anonymous client (that's a different term), first-time visitor

**Anonymous client**:
A client created where there is no request, e.g. in a console command or a worker. Its client id is never stored.
_Avoid_: new client, default client

**Client cookie**:
The cookie that carries a client's id together with its first-seen and last-seen timestamps.
_Avoid_: client id cookie, tracking cookie

**First seen**:
When the client id in the client cookie was first issued. It starts over when the current client's id differs from the cookie's.

**Last seen**:
When the client was last seen, i.e. the latest request that refreshed its client cookie.

**Metadata**:
Key/value data kept per client between visits, each key with an optional expiry.
_Avoid_: attributes, properties, client data
80 changes: 41 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Typical use cases: first‑party analytics, marketing attribution, returning‑v
- [Get the current client](#get-the-current-client)
- [Read and write metadata](#read-and-write-metadata)
- [Set metadata from a request](#set-metadata-from-a-request)
- [Access the cookie directly](#access-the-cookie-directly)
- [Read the cookie in JavaScript](#read-the-cookie-in-javascript)
- [Skip storing the cookie (consent)](#skip-storing-the-cookie-consent)
- [Configuration](#configuration)
- [How it works](#how-it-works)
Expand Down Expand Up @@ -109,6 +109,9 @@ final class SomeService
}
```

A request gets one client, however often you ask for it. Outside a request (console commands, workers) there is no
visitor to recognize, so every call returns an *anonymous* client with a new client id that is never stored.

### Read and write metadata

`$client->metadata` is a key/value store. Reading a key for the first time lazily loads the metadata from the
Expand Down Expand Up @@ -164,35 +167,11 @@ final class GoogleClickIdSubscriber implements EventSubscriberInterface
}
```

### Access the cookie directly

The cookie stores the client id plus the first/last seen timestamps. Read it through `CookieProviderInterface`:

```php
use Setono\ClientBundle\CookieProvider\CookieProviderInterface;

final class SomeService
{
public function __construct(private readonly CookieProviderInterface $cookieProvider)
{
}

public function __invoke(): void
{
$cookie = $this->cookieProvider->getCookie();
if (null === $cookie) {
return; // visitor has no cookie yet
}
### Read the cookie in JavaScript

$cookie->clientId; // the client id
$cookie->firstSeenAt; // unix timestamp of first visit
$cookie->lastSeenAt; // unix timestamp of the previous visit
}
}
```

The cookie is intentionally **not** `HttpOnly`, so you can also read it from JavaScript (e.g. to send the client id
to a third‑party tag).
The cookie is intentionally **not** `HttpOnly`, so you can read it from JavaScript (e.g. to send the client id to a
third‑party tag). Its value is `<version>.<first seen>.<last seen>.<client id>`, with the timestamps as unix timestamps,
e.g. `2.1700000000.1700086400.0190c3a2-...`.

### Skip storing the cookie (consent)

Expand Down Expand Up @@ -247,13 +226,14 @@ setono_client:

## How it works

- **Cookie** — on each main response, `StoreCookieSubscriber` writes/refreshes the cookie and bumps `lastSeenAt`.
A new client id is generated only when no valid cookie is present.
- **Current client** — `ClientContext` recognizes the client by the cookie of the main request. A request without a
valid cookie gets a *new client*, with a new client id. `CachedClientContext` wraps the client context, including
your decorators, and gives each request one client.
- **Cookie** — on each main response, `StoreCookieSubscriber` writes the cookie of the current client and bumps
`lastSeenAt`. The cookie always holds the client id of the current client: when that differs from the id in the
request's cookie (e.g. because a decorator chose another client), the cookie gets the new id and fresh timestamps.
- **Lazy metadata** — `$client->metadata` is a lazy ghost object. The database is queried only the first time you
read a value, and a row is written only at the end of the request, and only if the metadata was actually changed.
- **Resolution chain** — `ClientContextInterface` is built from a chain of decorating services
(`CachedClientContext` → `CookieBasedClientContext` → `DefaultClientContext`). The cached layer memoizes the client
per request; the cookie‑based layer builds it from the cookie; the default layer creates a fresh, anonymous client.

## Extending the bundle

Expand All @@ -262,18 +242,40 @@ Every moving part is a service behind an interface, so you can swap or decorate
| Concern | Interface |
| --- | --- |
| Resolving the current client | `Setono\ClientBundle\Context\ClientContextInterface` |
| Reading the cookie | `Setono\ClientBundle\CookieProvider\CookieProviderInterface` |
| Loading metadata | `Setono\ClientBundle\MetadataProvider\MetadataProviderInterface` |
| Persisting metadata | `Setono\ClientBundle\MetadataPersister\MetadataPersisterInterface` |
| Metadata entity | `Setono\ClientBundle\Entity\MetadataInterface` |

To add behaviour, [decorate](https://symfony.com/doc/current/service_container/service_decoration.html) the relevant
service rather than replacing it outright — that is exactly how the bundle composes its own defaults.

Decorate the `.default` service of a chain, e.g. `setono_client.client_context.default`, and choose its place with the
decoration priority. Don't decorate `setono_client.client_context.cached` directly: it is reset between requests in
long-running runtimes like FrankenPHP's worker mode, and Symfony would then reset your decorator instead. Your decorator
would have to implement `Symfony\Contracts\Service\ResetInterface` and pass the `reset()` call on.
To change which client a request gets, e.g. to recognize a logged‑in user's client on another device, decorate
`ClientContextInterface`:

```php
use Setono\Client\Client;
use Setono\ClientBundle\Context\ClientContextInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator(ClientContextInterface::class)]
final class UserClientContext implements ClientContextInterface
{
public function __construct(#[AutowireDecorated] private readonly ClientContextInterface $decorated)
{
}

public function getClient(): Client
{
// return another client, or the decorated one
return $this->decorated->getClient();
}
}
```

The bundle's `CachedClientContext` decorates the interface with the lowest priority (`-1024`), so it wraps your
decorator and a request gets one client however often it's asked for, even if your decorator creates a client on every
call. The cookie then stores the client id of your client. It must be a non‑empty string of at most 255 bytes.

## Contributing

Expand Down
28 changes: 28 additions & 0 deletions UPGRADE-2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Upgrade from 1.x to 2.0

## Client resolution

The client context and the cookie are now one module: `ClientContext` recognizes the client, `CachedClientContext` gives a request one client, and an internal `ClientCookieJar` reads and writes the cookie.

### Removed

- `Setono\ClientBundle\CookieProvider\CookieProviderInterface`, `RequestBasedCookieProvider` and `CachedCookieProvider`, with the services `setono_client.cookie_provider.default` and `setono_client.cookie_provider.cached`. To read the cookie's timestamps, parse the cookie of the request yourself:

```php
$cookie = \Setono\Client\Cookie::fromString($request->cookies->getString('setono_client_id')); // throws on an invalid cookie
```

- `Setono\ClientBundle\Context\CookieBasedClientContext` and `DefaultClientContext`, with the service `setono_client.client_context.cookie_based`. `Setono\ClientBundle\Context\ClientContext` (`setono_client.client_context.default`) replaces both.
- `CachedClientContext` no longer implements `Symfony\Contracts\Service\ResetInterface` and is no longer tagged `kernel.reset`. Its constructor takes a `RequestStack` as its second argument.
- The dependency on `symfony/service-contracts`.

### Changed

- `StoreCookieSubscriber::__construct()` takes `ClientContextInterface`, `ClientCookieJar` and the event dispatcher. The cookie name and expiration moved to `ClientCookieJar`.
- The cookie always holds the client id of the current client, i.e. what `ClientContextInterface::getClient()` returns, decorators included. In 1.x, a request with a cookie kept the cookie's client id. When the ids differ, the cookie gets the new client id and fresh first and last seen timestamps.
- The cookie writer throws a `LogicException` when the current client's id is empty or longer than 255 bytes, because the next request couldn't read it back.
- Outside a request (console commands, workers), every `getClient()` call returns a new anonymous client. In 1.x, the first one was kept until the container was reset.

### Decorating the client context

Decorate `Setono\ClientBundle\Context\ClientContextInterface`, e.g. with `#[AsDecorator(ClientContextInterface::class)]`. Decorating `setono_client.client_context.default` works too. You no longer need to pick a priority or implement `ResetInterface`. `CachedClientContext` decorates the interface with priority `-1024`, so it wraps your decorator, and a request gets one client even if your decorator creates a client on every call.
1 change: 0 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"symfony/event-dispatcher": "^6.4 || ^7.0",
"symfony/http-foundation": "^6.4 || ^7.0",
"symfony/http-kernel": "^6.4 || ^7.0",
"symfony/service-contracts": "^2.5 || ^3.0",
"symfony/uid": "^6.4 || ^7.0",
"symfony/var-exporter": "^6.4 || ^7.0"
},
Expand Down
Loading
Loading