Skip to content

Commit

Permalink
Adds offline conversion tracking (#4)
Browse files Browse the repository at this point in the history
* WIP

* WIP

* WIP

* Added readme and fixed phpunit

* Made the algorithm changeable

* Fixed static analysis issues

* Only returned the last three days of conversions

* Removed placeholder on category

* Add event

* Added plugin specific salt configuration value

* Added index on createdAt and channel

* Added upgrade file and updated changelog

* Added benefit of no JS

* Added csv header to conversion csv file
  • Loading branch information
loevgaard authored Jan 4, 2021
1 parent fe6f1ea commit 92e2dfc
Show file tree
Hide file tree
Showing 56 changed files with 1,172 additions and 567 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changelog
All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 1.0.0
### Changed
- Changed from client side to server side tracking. This change renders v0.1 completely different to v1.0. Do not
upgrade to v1.0 if you don't want server side tracking.
52 changes: 38 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,42 +1,66 @@
# Setono Sylius Google Ads Plugin
# Sylius plugin for Google Ads

[![Latest Version][ico-version]][link-packagist]
[![Latest Unstable Version][ico-unstable-version]][link-packagist]
[![Software License][ico-license]](LICENSE)
[![Build Status][ico-github-actions]][link-github-actions]

Plugin for tracking Google Ads related events and adding respective tags.
This plugin tracks conversions in your Sylius store. It's done with [offline conversion tracking](https://support.google.com/google-ads/answer/2998031?hl=en)
instead of the default javascript tracking. It has a few benefits to do this:
- Easier to control the consent status for a given user
- Easier to change the value of a given order after the fact
- No javascripts on your page to track Google Ads, which means faster page load

## Installation

### Step 1: Install required bundles

This plugin depends on the tag bag bundle, so install that bundle first:

- Install [tag bag bundle](https://github.com/Setono/TagBagBundle)

### Step 2: Install and enable plugin
### Step 1: Install and enable plugin

```bash
$ composer require setono/sylius-google-ads-plugin
```

This command requires you to have Composer installed globally, as explained in the [installation chapter](https://getcomposer.org/doc/00-intro.md) of the Composer documentation.

Add bundle to your `config/bundles.php`:
Add the bundle to your `config/bundles.php` before the `SyliusGridBundle`:

```php
<?php
# config/bundles.php

return [
// ...
Setono\SyliusGoogleAdsPlugin\SetonoSyliusGoogleAdsPlugin::class => ['all' => true],

Setono\SyliusGoogleAdsPlugin\SetonoSyliusGoogleAdsPlugin::class => ['all' => true], // Added before the grid bundle
Sylius\Bundle\GridBundle\SyliusGridBundle::class => ['all' => true],

// ...
];

```

That's it!

## Usage

Offline conversion tracking works like this:

1. We collect the `gclid` query parameter when a user enters the store from clicking on an ad. We save this value in a cookie.
2. When the same user completes a purchase, we will insert a new row into our `conversion` table.
3. We then expose these conversions as CSV data on a URL that you grab in the backend. This URL is then used when setting
up the conversion action inside the Google Ads interface.

### Step 1: Set up a new conversion action in Sylius
1. Go to `/admin/conversion-actions/new` and create a new conversion action. There's a help text on the right explaining
how to do it.

2. When you have created your conversion, you go to the conversion action index (`/admin/conversion-actions/`) where you
find the URL you need to give to Google. It will look something like: `https://your-domain.com/en_US/google-ads/conversions/af5717388cb5a610b92c9da43914384cfa8a491e5999e4e9c4e9e0b32204b0dc`

### Step 2: Set up a matching conversion action in Google Ads
1. Create a new conversion action inside the Google Ads interface.
2. Name it the same as you did in Sylius. This is **important** since Google matches the name.
3. Create a new upload of conversions. See the image below:

![Upload conversions](docs/images/conversion-uploads.png)


[ico-version]: https://poser.pugx.org/setono/sylius-google-ads-plugin/v/stable
[ico-unstable-version]: https://poser.pugx.org/setono/sylius-google-ads-plugin/v/unstable
[ico-license]: https://poser.pugx.org/setono/sylius-google-ads-plugin/license
Expand Down
14 changes: 14 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Upgrade from v0.1 to v1.0
1. If you are not using the [tag bag bundle](https://github.com/Setono/TagBagBundle) anywhere else in your application
you can safely remove it.

2. The entity that was named `Conversion` in v0.1 has been renamed to `ConversionAction` in v1.0 while a new `Conversion`
entity has been created. This means your Doctrine migration file should first rename old
`setono_sylius_google_ads__conversion` table to `setono_sylius_google_ads__conversion_action`.

3. Generate a random string and use it as a salt in the config:
```yaml
# config/packages/setono_sylius_google_ads.yaml
setono_sylius_google_ads:
salt: 'insert your random string here'
```
14 changes: 5 additions & 9 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,8 @@
"require": {
"php": "^7.4",
"psr/event-dispatcher": "^1.0",
"setono/tag-bag": "^1.0",
"setono/tag-bag-bundle": "^2.0",
"setono/tag-bag-gtag": "^1.2",
"setono/tag-bag-twig": "^1.1",
"sylius/order": "^1.0",
"sylius/resource-bundle": "^1.6",
"symfony/config": "^4.4 || ^5.0",
"symfony/dependency-injection": "^4.4 || ^5.0",
"symfony/event-dispatcher": "^4.4 || ^5.0",
"symfony/form": "^4.4 || ^5.0",
Expand All @@ -23,13 +20,12 @@
},
"require-dev": {
"matthiasnoback/symfony-config-test": "^4.2",
"matthiasnoback/symfony-dependency-injection-test": "^4.1",
"phpunit/phpunit": "^9.3",
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"setono/code-quality-pack": "^1.4",
"sylius/sylius": "^1.7",
"sylius/sylius": "~1.7.0",
"symfony/debug-bundle": "^5.1",
"symfony/dotenv": "^5.1",
"symfony/dotenv": "^5.2",
"symfony/intl": "^4.4 || ^5.0",
"symfony/maker-bundle": "^1.21",
"symfony/web-profiler-bundle": "^5.0"
Expand Down
Binary file added docs/images/conversion-uploads.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions src/ConsentChecker/AlwaysGivenConsentChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusGoogleAdsPlugin\ConsentChecker;

final class AlwaysGivenConsentChecker implements ConsentCheckerInterface
{
public function hasConsent(): bool
{
return true;
}
}
13 changes: 13 additions & 0 deletions src/ConsentChecker/ConsentCheckerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusGoogleAdsPlugin\ConsentChecker;

interface ConsentCheckerInterface
{
/**
* Returns true if the user has consented to sending data to Google
*/
public function hasConsent(): bool;
}
102 changes: 102 additions & 0 deletions src/Controller/Action/DownloadConversionsAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusGoogleAdsPlugin\Controller\Action;

use function Safe\fopen;
use function Safe\fputcsv;
use function Safe\sprintf;
use Setono\SyliusGoogleAdsPlugin\KeyGenerator\KeyGeneratorInterface;
use Setono\SyliusGoogleAdsPlugin\Model\ConversionInterface;
use Setono\SyliusGoogleAdsPlugin\Repository\ConversionRepositoryInterface;
use Sylius\Component\Channel\Context\ChannelContextInterface;
use Sylius\Component\Channel\Model\ChannelInterface;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

final class DownloadConversionsAction
{
private ChannelContextInterface $channelContext;

private ConversionRepositoryInterface $conversionRepository;

private KeyGeneratorInterface $keyGenerator;

public function __construct(
ChannelContextInterface $channelContext,
ConversionRepositoryInterface $conversionRepository,
KeyGeneratorInterface $keyGenerator
) {
$this->channelContext = $channelContext;
$this->conversionRepository = $conversionRepository;
$this->keyGenerator = $keyGenerator;
}

public function __invoke(Request $request, string $key): Response
{
$channel = $this->channelContext->getChannel();
if (!$this->keyGenerator->check($channel, $key)) {
throw new NotFoundHttpException('The page you are looking for does not exist'); // todo throw an unauthorized exception?
}

$qb = $this->conversionRepository->findByChannelQueryBuilder($channel);
$manager = $qb->getEntityManager();
$iterableResult = $qb->getQuery()->iterate();

$response = new StreamedResponse(function () use ($manager, $iterableResult): void {
$output = fopen('php://output', 'wb');

fputcsv($output, [sprintf('Parameters:TimeZone=%s', date_default_timezone_get())]);
fputcsv($output, ['Google Click ID', 'Conversion Name', 'Conversion Time', 'Conversion Value', 'Conversion Currency']);

foreach ($iterableResult as $row) {
/** @var ConversionInterface $conversion */
$conversion = $row[0];

$createdAt = $conversion->getCreatedAt();
if (null === $createdAt) {
throw new \LogicException(sprintf(
'The created at timestamp on the conversion with id %s is null. This should not be possible.',
$conversion->getId()
));
}

fputcsv($output, [
$conversion->getGoogleClickId(), $conversion->getName(), $createdAt->format('Y-m-d\TH:i:s'),
self::formatValue((int) $conversion->getValue()), $conversion->getCurrencyCode(),
]);

$manager->detach($row[0]);

flush();
}

flush();
}, 200, [
'Content-Type' => 'text/csv',
]);

$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
self::generateFilename($channel)
);

$response->headers->set('Content-Disposition', $disposition);

return $response;
}

private static function formatValue(int $value): float
{
return round($value / 100, 2);
}

private static function generateFilename(ChannelInterface $channel): string
{
return 'conversions---' . mb_strtolower((string) $channel->getCode()) . '.csv';
}
}
32 changes: 32 additions & 0 deletions src/Controller/Action/ShowHelpAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusGoogleAdsPlugin\Controller\Action;

use Setono\SyliusGoogleAdsPlugin\Repository\ConversionActionRepositoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;

final class ShowHelpAction
{
private Environment $twig;

private ConversionActionRepositoryInterface $conversionRepository;

public function __construct(
Environment $twig,
ConversionActionRepositoryInterface $conversionRepository
) {
$this->twig = $twig;
$this->conversionRepository = $conversionRepository;
}

public function __invoke(Request $request): Response
{
return new Response($this->twig->render('@SetonoSyliusGoogleAdsPlugin/admin/help.html.twig', [
'channels' => $this->conversionRepository->findChannels(),
]));
}
}
39 changes: 28 additions & 11 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@

namespace Setono\SyliusGoogleAdsPlugin\DependencyInjection;

use function method_exists;
use Setono\SyliusGoogleAdsPlugin\Doctrine\ORM\ConversionActionRepository;
use Setono\SyliusGoogleAdsPlugin\Doctrine\ORM\ConversionRepository;
use Setono\SyliusGoogleAdsPlugin\Form\Type\ConversionType;
use Setono\SyliusGoogleAdsPlugin\Form\Type\ConversionActionType;
use Setono\SyliusGoogleAdsPlugin\Model\Conversion;
use Setono\SyliusGoogleAdsPlugin\Model\ConversionInterface;
use Setono\SyliusGoogleAdsPlugin\Model\ConversionAction;
use Sylius\Bundle\ResourceBundle\Controller\ResourceController;
use Sylius\Bundle\ResourceBundle\Form\Type\DefaultResourceType;
use Sylius\Bundle\ResourceBundle\SyliusResourceBundle;
use Sylius\Component\Resource\Factory\Factory;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
Expand All @@ -21,12 +22,7 @@ final class Configuration implements ConfigurationInterface
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('setono_sylius_google_ads');
if (method_exists($treeBuilder, 'getRootNode')) {
$rootNode = $treeBuilder->getRootNode();
} else {
// BC layer for symfony/config 4.1 and older
$rootNode = $treeBuilder->root('setono_sylius_google_ads');
}
$rootNode = $treeBuilder->getRootNode();

$rootNode
->addDefaultsIfNotSet()
Expand All @@ -35,6 +31,12 @@ public function getConfigTreeBuilder(): TreeBuilder
->defaultValue(SyliusResourceBundle::DRIVER_DOCTRINE_ORM)
->cannotBeEmpty()
->end()
->scalarNode('salt')
->info('The salt is used to generate the keys for the URLs used for downloading conversions. It is a good idea to set this value so it is independent of the kernel.secret.')
->example('l0ng$tringth4t1$n0te4$y2guess')
->defaultValue('%kernel.secret%')
->cannotBeEmpty()
->end()
->end()
;

Expand All @@ -58,11 +60,26 @@ private function addResourcesSection(ArrayNodeDefinition $node): void
->addDefaultsIfNotSet()
->children()
->scalarNode('model')->defaultValue(Conversion::class)->cannotBeEmpty()->end()
->scalarNode('interface')->defaultValue(ConversionInterface::class)->cannotBeEmpty()->end()
->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty()->end()
->scalarNode('repository')->defaultValue(ConversionRepository::class)->cannotBeEmpty()->end()
->scalarNode('factory')->defaultValue(Factory::class)->end()
->scalarNode('form')->defaultValue(ConversionType::class)->cannotBeEmpty()->end()
->scalarNode('form')->defaultValue(DefaultResourceType::class)->cannotBeEmpty()->end()
->end()
->end()
->end()
->end()
->arrayNode('conversion_action')
->addDefaultsIfNotSet()
->children()
->variableNode('options')->end()
->arrayNode('classes')
->addDefaultsIfNotSet()
->children()
->scalarNode('model')->defaultValue(ConversionAction::class)->cannotBeEmpty()->end()
->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty()->end()
->scalarNode('repository')->defaultValue(ConversionActionRepository::class)->cannotBeEmpty()->end()
->scalarNode('factory')->defaultValue(Factory::class)->end()
->scalarNode('form')->defaultValue(ConversionActionType::class)->cannotBeEmpty()->end()
->end()
->end()
->end()
Expand Down
2 changes: 2 additions & 0 deletions src/DependencyInjection/SetonoSyliusGoogleAdsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public function load(array $config, ContainerBuilder $container): void
$config = $this->processConfiguration($this->getConfiguration([], $container), $config);
$loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));

$container->setParameter('setono_sylius_google_ads.salt', $config['salt']);

$loader->load('services.xml');

$this->registerResources('setono_sylius_google_ads', $config['driver'], $config['resources'], $container);
Expand Down
Loading

0 comments on commit 92e2dfc

Please sign in to comment.