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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"require": {
"php": ">=8.1",
"composer/semver": "^3.0",
"psr/log": "^1.1 || ^2.0 || ^3.0",
"setono/bot-detection-bundle": "^1.7",
"setono/consent-contracts": "^1.1",
"setono/meta-conversions-api-php-sdk": "^1.1",
Expand Down
52 changes: 43 additions & 9 deletions src/Context/Fbc/CookieBasedFbcContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,31 @@

namespace Setono\MetaConversionsApiBundle\Context\Fbc;

use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Setono\MetaConversionsApi\ValueObject\Fbc;
use Symfony\Component\HttpFoundation\RequestStack;

final class CookieBasedFbcContext implements FbcContextInterface
{
public function __construct(private readonly RequestStack $requestStack)
{
/**
* Matches the _fbc cookie as Meta writes it today: fb.<subdomain index>.<creation time>.<click id> with an
* optional trailing appendix segment.
*
* This is deliberately more lenient than Fbc::fromString() in the SDK, which only accepts alphanumeric click ids
* and exactly four segments. Real click ids are base64url and contain - and _, and Meta's own parameter builder
* (facebook/capi-param-builder-php) appends a 2 or 8 character appendix, so the strict pattern rejects cookies

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use the facebook/capi-param-builder-php if possible?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth doing, but I would not fold it into this PR. I ran it against the current vendored copy (1.3.1, it arrives transitively through facebook/php-business-sdk) to get real numbers:

$b = new FacebookAds\ParamBuilder();
$cookies = $b->processRequest('www.example.com', ['fbclid' => 'IwAR1a-b_c'], ['_fbp' => 'fb.1.1657051589577.1088522659']);

// fbc: 'fb.1.1788781160733.IwAR1a-b_c.AQECAQMB'
// fbp: 'fb.1.1657051589577.1088522659.AQEAAQMB'
// cookie _fbc domain=example.com   <- eTLD+1, not the host

That confirms two things the review found: Meta writes a five segment value with an eight character appendix, and it sets the cookie on the registrable domain rather than host-only.

What speaks for it

What speaks against doing it here

  • It would have to become a direct require. Today we only get it because php-business-sdk pulls it in.
  • It targets PHP 7.4 with no parameter or return types, so at PHPStan level max every call site needs narrowing.
  • It is a whole-request abstraction: one call computes fbc, fbp, ip and source url and returns the cookies to set. Our Context classes are small decorators a user can swap one at a time. Adopting it means redesigning Context/Fbc, Context/Fbp, StoreFbcSubscriber and StoreFbpSubscriber, and deciding what FbcContextInterface::getFbc(): ?Fbc returns, since the builder deals in strings.
  • It returns strings rather than the SDK's Fbc/Fbp value objects. User::$fbc accepts string|Fbc|null so it would work, but our own interfaces would change.

So: a good idea, and a design change rather than a bug fix. I opened #45 for it. This PR stays the minimal fix so the cookies Meta writes today are actually read, whichever way that decision goes.

* that the browser pixel writes
*/
private const COOKIE_PATTERN = '/^fb\.([012])\.(\d{13})\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]{2,8}))?$/';

private readonly LoggerInterface $logger;

public function __construct(
private readonly RequestStack $requestStack,
?LoggerInterface $logger = null,
) {
$this->logger = $logger ?? new NullLogger();
}

public function getFbc(): ?Fbc
Expand All @@ -20,14 +38,30 @@ public function getFbc(): ?Fbc
return null;
}

$fbc = $request->cookies->get('_fbc');
if (is_string($fbc)) {
try {
return Fbc::fromString($fbc);
} catch (\InvalidArgumentException) {
}
$cookie = $request->cookies->get('_fbc');
if (!is_string($cookie) || '' === $cookie) {
return null;
}

return null;
if (1 !== preg_match(self::COOKIE_PATTERN, $cookie, $matches)) {
$this->logger->debug('The _fbc cookie value "{value}" could not be parsed and is ignored', ['value' => $cookie]);

return null;
}

try {
return (new Fbc($matches[3]))
->withSubdomainIndex((int) $matches[1])
->withCreationTime((int) $matches[2])
;
} catch (\InvalidArgumentException $e) {
// The creation time is in the future or predates Facebook
$this->logger->debug('The _fbc cookie value "{value}" was rejected: {message}', [
'value' => $cookie,
'message' => $e->getMessage(),
]);

return null;
Comment on lines +46 to +64

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why put the code here instead of rewriting Fbc::fromString?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, it belongs there. I moved it: Setono/meta-conversions-api-php-sdk#13.

My reason for putting it here was that the SDK is a separate release cycle, which is a weak argument when you maintain both, and it left the bundle carrying a second parser next to the SDK's.

Chasing it into the SDK also turned up something my review missed: Fbp::fromString() has the same bug, and there it is worse.

Fbp::fromString('fb.1.1657051589577.1088522659.AQEAAQMB'); // throws

That is the shape Meta's parameter builder writes. CookieBasedFbcContext returning null costs you the click id. CookieBasedFbpContext falling through to GeneratedFbpContext means the server mints a brand new fbp on every single request while the browser has a stable one, so the two sides stop describing the same person. Nothing throws where you can see it, and the only symptom is a lower Event Match Quality.

The SDK PR relaxes both patterns and preserves the appendix, so a value read from a cookie is written back byte for byte rather than being rewritten into a shape the pixel does not expect. Coverage stays at 100% and Infection stays above the threshold.

Suggested order. This PR is blocked until that one is released, since CI here would still resolve ^1.1. Once you merge and tag the SDK, I will reduce this PR to: call Fbc::fromString() again, keep the debug logging for a cookie that cannot be parsed, fix the _fbp path the same way, and bump the constraint. That deletes more code here than it adds.

Say the word if you would rather I park this one and reopen it after the tag.

}
}
}
3 changes: 3 additions & 0 deletions src/Resources/config/services/context.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

<service id="Setono\MetaConversionsApiBundle\Context\Fbc\CookieBasedFbcContext">
<argument type="service" id="request_stack"/>
<argument type="service" id="logger" on-invalid="null"/>

<tag name="monolog.logger" channel="setono_meta_conversions_api"/>
</service>

<service id="Setono\MetaConversionsApiBundle\Context\Fbc\CachedFbcContext"
Expand Down
107 changes: 107 additions & 0 deletions tests/Unit/Context/Fbc/CookieBasedFbcContextTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApiBundle\Tests\Unit\Context\Fbc;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Setono\MetaConversionsApi\ValueObject\Fbc;
use Setono\MetaConversionsApiBundle\Context\Fbc\CookieBasedFbcContext;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;

#[CoversClass(CookieBasedFbcContext::class)]
final class CookieBasedFbcContextTest extends TestCase
{
#[Test]
public function it_returns_null_without_a_request(): void
{
self::assertNull((new CookieBasedFbcContext(new RequestStack()))->getFbc());
}

#[Test]
public function it_returns_null_without_a_cookie(): void
{
self::assertNull((new CookieBasedFbcContext(self::requestStack(null)))->getFbc());
}

#[Test]
#[DataProvider('cookies')]
public function it_parses_the_cookie(string $cookie, string $expectedClickId, int $expectedSubdomainIndex, int $expectedCreationTime): void
{
$fbc = (new CookieBasedFbcContext(self::requestStack($cookie)))->getFbc();

self::assertInstanceOf(Fbc::class, $fbc);
self::assertSame($expectedClickId, $fbc->getClickId());
self::assertSame($expectedSubdomainIndex, $fbc->getSubdomainIndex());
self::assertSame($expectedCreationTime, $fbc->getCreationTime());
}

/**
* @return iterable<string, array{string, string, int, int}>
*/
public static function cookies(): iterable
{
yield 'alphanumeric click id' => [
'fb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA',
'IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA',
1,
1657051589577,
];

// Real click ids are base64url, so they contain - and _
yield 'base64url click id' => [
'fb.1.1657051589577.IwZXh0bgNhZW0CMTAAAR-uK_5w',
'IwZXh0bgNhZW0CMTAAAR-uK_5w',
1,
1657051589577,
];

// Meta's own parameter builder appends a 2 or 8 character appendix
yield 'appendix v1' => [
'fb.2.1657051589577.IwAR0rmfgHgx.AQ',
'IwAR0rmfgHgx',
2,
1657051589577,
];

yield 'appendix v2' => [
'fb.0.1657051589577.IwAR0rmfgHgx.AbCdEfGh',
'IwAR0rmfgHgx',
0,
1657051589577,
];
}

#[Test]
#[DataProvider('invalidCookies')]
public function it_returns_null_for_an_unparseable_cookie(string $cookie): void
{
self::assertNull((new CookieBasedFbcContext(self::requestStack($cookie)))->getFbc());
}

/**
* @return iterable<string, array{string}>
*/
public static function invalidCookies(): iterable
{
yield 'empty' => [''];
yield 'garbage' => ['not-an-fbc-cookie'];
yield 'wrong prefix' => ['xx.1.1657051589577.abc'];
yield 'invalid subdomain index' => ['fb.3.1657051589577.abc'];
yield 'short creation time' => ['fb.1.165705158.abc'];
yield 'missing click id' => ['fb.1.1657051589577.'];
yield 'creation time in the future' => ['fb.1.9999999999999.abc'];
}

private static function requestStack(?string $cookie): RequestStack
{
$requestStack = new RequestStack();
$requestStack->push(new Request([], [], [], null === $cookie ? [] : ['_fbc' => $cookie]));

return $requestStack;
}
}
Loading