diff --git a/README.md b/README.md index 549d4c9..418771a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,12 @@ setono_meta_conversions_api: cookies: fbp: true fbc: true + # The domain to write them on. Meta's own pixel uses the registrable domain, so set this to yours if your + # site is reachable on both the apex and www, or spans several subdomains. Null scopes them to the current + # host, which means apex and www get different cookies + domain: null + # Anything \DateTimeImmutable understands. Meta keeps these for 90 days + lifetime: '+90 days' # The PSR-18 http client used to send events. Defaults to Symfony's default http client, which means requests # to Meta show up in the profiler and honour the options you configured. Point it at a scoped client to give diff --git a/UPGRADE.md b/UPGRADE.md index 1a65cc0..cacb97b 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -75,6 +75,27 @@ anything to `framework.messenger`. `SendEvent` is dispatched on your application `?ConsentContextInterface $consentContext` and `bool $consentEnabled` / `bool $clientSideEnabled` / `bool $serverSideEnabled` arguments. Adapt subclasses, decorators and custom service definitions. +## Cookies + +New `cookies` options: + +```yaml +setono_meta_conversions_api: + cookies: + fbp: true + fbc: true + domain: null # e.g. example.com + lifetime: '+90 days' +``` + +The `_fbp` and `_fbc` cookies are now only written on a 2xx or 3xx response and only when at least one pixel is +available, because every `Set-Cookie` header makes a response uncacheable for shared caches. + +The subdomain index encoded in the value (the `1` in `fb.1.…`) is now derived from the domain the cookie is actually +written on, the same way Meta's parameter builder does it, instead of always being `1`. On a host-only cookie on +`www.example.com` the value is now `fb.2.…`. Set `cookies.domain` to your registrable domain to get `fb.1.…` and one +cookie shared between the apex and `www`. + ## The SendEvent command changed shape `SendEvent` no longer carries the `Setono\MetaConversionsApi\Event\Event` object. It carries the finished payload diff --git a/src/Context/Fbc/QueryBasedFbcContext.php b/src/Context/Fbc/QueryBasedFbcContext.php index 883ba2e..0f6a6bf 100644 --- a/src/Context/Fbc/QueryBasedFbcContext.php +++ b/src/Context/Fbc/QueryBasedFbcContext.php @@ -5,6 +5,7 @@ namespace Setono\MetaConversionsApiBundle\Context\Fbc; use Setono\MetaConversionsApi\ValueObject\Fbc; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Symfony\Component\HttpFoundation\RequestStack; final class QueryBasedFbcContext implements FbcContextInterface @@ -20,6 +21,7 @@ final class QueryBasedFbcContext implements FbcContextInterface public function __construct( private readonly FbcContextInterface $decorated, private readonly RequestStack $requestStack, + private readonly CookieDomain $cookieDomain, ) { } @@ -35,6 +37,7 @@ public function getFbc(): ?Fbc return $this->decorated->getFbc(); } - return new Fbc($facebookClickId); + // The click id is about to be written as a cookie, so it has to say which level it was set at + return (new Fbc($facebookClickId))->withSubdomainIndex($this->cookieDomain->subdomainIndex()); } } diff --git a/src/Context/Fbp/GeneratedFbpContext.php b/src/Context/Fbp/GeneratedFbpContext.php index d028765..e593858 100644 --- a/src/Context/Fbp/GeneratedFbpContext.php +++ b/src/Context/Fbp/GeneratedFbpContext.php @@ -5,11 +5,17 @@ namespace Setono\MetaConversionsApiBundle\Context\Fbp; use Setono\MetaConversionsApi\ValueObject\Fbp; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; final class GeneratedFbpContext implements FbpContextInterface { + public function __construct(private readonly CookieDomain $cookieDomain) + { + } + public function getFbp(): Fbp { - return new Fbp(); + // The generated value is about to be written as a cookie, so it has to say which level it was set at + return (new Fbp())->withSubdomainIndex($this->cookieDomain->subdomainIndex()); } } diff --git a/src/Cookie/CookieDomain.php b/src/Cookie/CookieDomain.php new file mode 100644 index 0000000..92d7266 --- /dev/null +++ b/src/Cookie/CookieDomain.php @@ -0,0 +1,48 @@ +domain; + } + + /** + * The number of dots in the domain the cookie ends up on, which is how Meta's own parameter builder + * computes it (`substr_count($etldPlus1, '.')`) + */ + public function subdomainIndex(): int + { + $domain = $this->domain ?? $this->requestStack->getMainRequest()?->getHost(); + if (null === $domain || '' === $domain) { + return 0; + } + + // A leading dot is the classic way of writing a domain cookie and says nothing about the level + return min(2, substr_count(ltrim($domain, '.'), '.')); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 51e1ee3..076fc50 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -7,6 +7,7 @@ use Composer\InstalledVersions; use Composer\Semver\VersionParser; use Setono\Consent\DefaultConsents; +use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -74,6 +75,15 @@ public function getConfigTreeBuilder(): TreeBuilder ->children() ->booleanNode('fbp')->defaultTrue()->end() ->booleanNode('fbc')->defaultTrue()->end() + ->scalarNode('domain') + ->info('The domain to write the cookies on, e.g. example.com, so the apex and www share one cookie the way Meta\'s own pixel does. Null scopes them to the current host') + ->defaultNull() + ->end() + ->scalarNode('lifetime') + ->info('Anything \DateTimeImmutable understands. Meta keeps these for 90 days') + ->defaultValue(Cookies::LIFETIME) + ->cannotBeEmpty() + ->end() ->end() ->end() ->scalarNode('http_client') diff --git a/src/DependencyInjection/SetonoMetaConversionsApiExtension.php b/src/DependencyInjection/SetonoMetaConversionsApiExtension.php index eb29889..49aff52 100644 --- a/src/DependencyInjection/SetonoMetaConversionsApiExtension.php +++ b/src/DependencyInjection/SetonoMetaConversionsApiExtension.php @@ -26,7 +26,7 @@ public function getConfiguration(array $config, ContainerBuilder $container): Co public function load(array $configs, ContainerBuilder $container): void { /** - * @var array{consent: array{enabled: bool, category: string}, client_side: array{enabled: bool}, server_side: array{enabled: bool, message_bus: string}, pixels: array, http_client: string, test_event_code: array{query_parameter: bool, value: string|null}, cookies: array{fbp: bool, fbc: bool}, filters: array{user_agent: list}} $config + * @var array{consent: array{enabled: bool, category: string}, client_side: array{enabled: bool}, server_side: array{enabled: bool, message_bus: string}, pixels: array, http_client: string, test_event_code: array{query_parameter: bool, value: string|null}, cookies: array{fbp: bool, fbc: bool, domain: string|null, lifetime: string}, filters: array{user_agent: list}} $config */ $config = $this->processConfiguration($this->getConfiguration([], $container), $configs); // The XML format is deprecated since Symfony 7.4 and removed in 8.0. Migrate to PHP config before adding Symfony 8 support @@ -39,6 +39,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('setono_meta_conversions_api.pixels', $config['pixels']); $container->setParameter('setono_meta_conversions_api.filters.user_agent', $config['filters']['user_agent']); + $cookieDomain = $config['cookies']['domain']; + $container->setParameter('setono_meta_conversions_api.cookies.domain', '' === $cookieDomain ? null : $cookieDomain); + $container->setParameter('setono_meta_conversions_api.cookies.lifetime', $config['cookies']['lifetime']); + $testEventCode = $config['test_event_code']['value']; $container->setParameter('setono_meta_conversions_api.test_event_code.value', '' === $testEventCode ? null : $testEventCode); $container->setParameter('setono_meta_conversions_api.test_event_code.query_parameter', $config['test_event_code']['query_parameter']); diff --git a/src/EventSubscriber/StoreFbcSubscriber.php b/src/EventSubscriber/StoreFbcSubscriber.php index 6705b36..5b48b0c 100644 --- a/src/EventSubscriber/StoreFbcSubscriber.php +++ b/src/EventSubscriber/StoreFbcSubscriber.php @@ -6,6 +6,7 @@ use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -24,6 +25,8 @@ public function __construct( private readonly FbcContextInterface $fbcContext, private readonly ConsentCheckerInterface $consentChecker, private readonly PixelProviderInterface $pixelProvider, + private readonly CookieDomain $cookieDomain, + private readonly string $lifetime = Cookies::LIFETIME, ) { } @@ -70,7 +73,9 @@ public function store(ResponseEvent $event): void $response->headers->setCookie(Cookie::create( Cookies::FBC, $fbc->value(), - new \DateTimeImmutable(Cookies::LIFETIME), + new \DateTimeImmutable($this->lifetime), + '/', + $this->cookieDomain->domain(), )->withHttpOnly(false)); // we need this to allow the js library to also use the cookie value } } diff --git a/src/EventSubscriber/StoreFbpSubscriber.php b/src/EventSubscriber/StoreFbpSubscriber.php index a006f17..dc2ca36 100644 --- a/src/EventSubscriber/StoreFbpSubscriber.php +++ b/src/EventSubscriber/StoreFbpSubscriber.php @@ -7,6 +7,7 @@ use Setono\MetaConversionsApi\ValueObject\Fbp; use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbp\FbpContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -26,6 +27,8 @@ public function __construct( private readonly FbpContextInterface $fbpContext, private readonly ConsentCheckerInterface $consentChecker, private readonly PixelProviderInterface $pixelProvider, + private readonly CookieDomain $cookieDomain, + private readonly string $lifetime = Cookies::LIFETIME, ) { } @@ -68,7 +71,9 @@ public function store(ResponseEvent $event): void $response->headers->setCookie(Cookie::create( Cookies::FBP, $fbp->value(), - new \DateTimeImmutable(Cookies::LIFETIME), + new \DateTimeImmutable($this->lifetime), + '/', + $this->cookieDomain->domain(), )->withHttpOnly(false)); // we need this to allow the js library to also use the cookie value } diff --git a/src/Resources/config/services/context.xml b/src/Resources/config/services/context.xml index a68a6da..c00fb6d 100644 --- a/src/Resources/config/services/context.xml +++ b/src/Resources/config/services/context.xml @@ -3,6 +3,11 @@ + + + %setono_meta_conversions_api.cookies.domain% + + @@ -22,6 +27,7 @@ decorates="Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface" decoration-priority="64"> + @@ -29,6 +35,7 @@ alias="Setono\MetaConversionsApiBundle\Context\Fbp\GeneratedFbpContext"/> + + + %setono_meta_conversions_api.cookies.lifetime% @@ -64,6 +66,8 @@ + + %setono_meta_conversions_api.cookies.lifetime% @@ -72,6 +76,8 @@ + + %setono_meta_conversions_api.cookies.lifetime% diff --git a/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php b/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php index 37c0109..7384ae1 100644 --- a/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php +++ b/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php @@ -11,6 +11,7 @@ use Setono\MetaConversionsApi\ValueObject\Fbc; use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface; use Setono\MetaConversionsApiBundle\Context\Fbc\QueryBasedFbcContext; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -22,7 +23,7 @@ public function it_falls_back_to_the_decorated_context_without_a_request(): void { $fbc = new Fbc('decorated'); - $context = new QueryBasedFbcContext(self::decorated($fbc), new RequestStack()); + $context = new QueryBasedFbcContext(self::decorated($fbc), new RequestStack(), new CookieDomain(new RequestStack())); self::assertSame($fbc, $context->getFbc()); } @@ -31,7 +32,7 @@ public function it_falls_back_to_the_decorated_context_without_a_request(): void #[DataProvider('validClickIds')] public function it_uses_a_valid_click_id_from_the_query(string $clickId): void { - $context = new QueryBasedFbcContext(self::decorated(null), self::requestStack($clickId)); + $context = new QueryBasedFbcContext(self::decorated(null), self::requestStack($clickId), new CookieDomain(new RequestStack())); $fbc = $context->getFbc(); @@ -56,7 +57,7 @@ public function it_falls_back_to_the_decorated_context_for_an_invalid_click_id(s { $decorated = new Fbc('decorated'); - $context = new QueryBasedFbcContext(self::decorated($decorated), self::requestStack($clickId)); + $context = new QueryBasedFbcContext(self::decorated($decorated), self::requestStack($clickId), new CookieDomain(new RequestStack())); self::assertSame($decorated, $context->getFbc()); } @@ -73,6 +74,21 @@ public static function invalidClickIds(): iterable yield 'dot' => ['fb.1.123.abc']; } + #[Test] + public function it_records_the_level_the_cookie_will_be_written_at(): void + { + $context = new QueryBasedFbcContext( + self::decorated(null), + self::requestStack('IwAR0rmfgHgx'), + new CookieDomain(new RequestStack(), 'www.example.com'), + ); + + $fbc = $context->getFbc(); + + self::assertInstanceOf(Fbc::class, $fbc); + self::assertSame(2, $fbc->getSubdomainIndex()); + } + private static function decorated(?Fbc $fbc): FbcContextInterface { return new class($fbc) implements FbcContextInterface { diff --git a/tests/Unit/Context/Fbp/GeneratedFbpContextTest.php b/tests/Unit/Context/Fbp/GeneratedFbpContextTest.php new file mode 100644 index 0000000..98fc33e --- /dev/null +++ b/tests/Unit/Context/Fbp/GeneratedFbpContextTest.php @@ -0,0 +1,38 @@ +getFbp()->value(), $context->getFbp()->value()); + } + + #[Test] + public function it_records_the_level_the_cookie_will_be_written_at(): void + { + $requestStack = new RequestStack(); + $requestStack->push(Request::create('https://www.example.com/')); + + // Host-only cookie on www.example.com, so the value has to say fb.2. + self::assertSame(2, (new GeneratedFbpContext(new CookieDomain($requestStack)))->getFbp()->getSubdomainIndex()); + + // ... but a cookie written on example.com is fb.1. + self::assertSame(1, (new GeneratedFbpContext(new CookieDomain($requestStack, 'example.com')))->getFbp()->getSubdomainIndex()); + } +} diff --git a/tests/Unit/Cookie/CookieDomainTest.php b/tests/Unit/Cookie/CookieDomainTest.php new file mode 100644 index 0000000..fd7a5c7 --- /dev/null +++ b/tests/Unit/Cookie/CookieDomainTest.php @@ -0,0 +1,77 @@ +domain()); + } + + #[Test] + public function it_returns_the_configured_domain(): void + { + self::assertSame('example.com', (new CookieDomain(new RequestStack(), 'example.com'))->domain()); + } + + /** + * Meta's own parameter builder computes this as the number of dots in the domain the cookie ends up on + */ + #[Test] + #[DataProvider('configuredDomains')] + public function it_derives_the_subdomain_index_from_the_configured_domain(string $domain, int $expected): void + { + self::assertSame($expected, (new CookieDomain(new RequestStack(), $domain))->subdomainIndex()); + } + + /** + * @return iterable + */ + public static function configuredDomains(): iterable + { + yield 'single label' => ['localhost', 0]; + yield 'registrable domain' => ['example.com', 1]; + yield 'leading dot' => ['.example.com', 1]; + yield 'subdomain' => ['www.example.com', 2]; + yield 'deep subdomain is capped' => ['shop.eu.example.com', 2]; + } + + #[Test] + #[DataProvider('hosts')] + public function it_falls_back_to_the_request_host(string $host, int $expected): void + { + $requestStack = new RequestStack(); + $requestStack->push(Request::create('https://' . $host . '/')); + + self::assertSame($expected, (new CookieDomain($requestStack))->subdomainIndex()); + } + + /** + * @return iterable + */ + public static function hosts(): iterable + { + yield 'single label' => ['localhost', 0]; + yield 'registrable domain' => ['example.com', 1]; + yield 'subdomain' => ['www.example.com', 2]; + } + + #[Test] + public function it_returns_zero_without_a_request_or_a_domain(): void + { + self::assertSame(0, (new CookieDomain(new RequestStack()))->subdomainIndex()); + } +} diff --git a/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php b/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php index 3a958b5..441e47c 100644 --- a/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php +++ b/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php @@ -11,11 +11,13 @@ use Setono\MetaConversionsApi\ValueObject\Fbp; use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbp\FbpContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\EventSubscriber\StoreFbpSubscriber; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -123,6 +125,30 @@ public function it_renews_a_cookie_older_than_two_hours(): void self::assertNotNull(self::cookie($event)); } + #[Test] + public function it_writes_a_host_only_cookie_by_default(): void + { + $event = self::event(new Request(), new Response()); + + self::subscriber()->store($event); + + $cookie = self::cookie($event); + self::assertNotNull($cookie); + self::assertNull($cookie->getDomain()); + } + + #[Test] + public function it_writes_the_cookie_on_the_configured_domain(): void + { + $event = self::event(new Request(), new Response()); + + self::subscriber(domain: 'example.com')->store($event); + + $cookie = self::cookie($event); + self::assertNotNull($cookie); + self::assertSame('example.com', $cookie->getDomain()); + } + private static function cookie(ResponseEvent $event): ?Cookie { foreach ($event->getResponse()->headers->getCookies() as $cookie) { @@ -137,7 +163,7 @@ private static function cookie(ResponseEvent $event): ?Cookie /** * @param list|null $pixels */ - private static function subscriber(?Fbp $fbp = null, bool $consentGranted = true, ?array $pixels = null): StoreFbpSubscriber + private static function subscriber(?Fbp $fbp = null, bool $consentGranted = true, ?array $pixels = null, ?string $domain = null): StoreFbpSubscriber { return new StoreFbpSubscriber( new class($fbp ?? new Fbp()) implements FbpContextInterface { @@ -173,6 +199,7 @@ public function getPixels(): array return $this->pixels; } }, + new CookieDomain(new RequestStack(), $domain), ); }