Skip to content

Commit df5e962

Browse files
authored
Add failed() logging and event to ProductImport (#326)
1 parent a548732 commit df5e962

6 files changed

Lines changed: 150 additions & 2 deletions

File tree

docs/content/en/CMS/importing-data.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,29 @@ In multi-store mode you can specify the store the product belongs to:
6262
php artisan shopify:import:product ID_HERE --store=uk
6363
```
6464

65+
## Import Failures
66+
67+
When an import job fails after all retries are exhausted, two things happen automatically:
68+
69+
1. The error is written to your Laravel log with the product ID and, in multi-store mode, the store handle.
70+
2. A `ProductImportFailed` event is fired, which you can listen for to add your own handling (notifications, alerts, etc.).
71+
72+
```php
73+
use StatamicRadPack\Shopify\Events\ProductImportFailed;
74+
75+
class AppServiceProvider extends ServiceProvider
76+
{
77+
public function boot(): void
78+
{
79+
Event::listen(ProductImportFailed::class, function (ProductImportFailed $event) {
80+
// $event->productId — the Shopify product ID
81+
// $event->storeHandle — the store handle (null in single-store mode)
82+
// $event->exception — the Throwable that caused the failure
83+
});
84+
}
85+
}
86+
```
87+
6588
## API Rate Limiting
6689

6790
The importer automatically handles Shopify's GraphQL API throttling. After each query, it inspects the `extensions.cost.throttleStatus` returned by Shopify. If the available query budget drops below 500 points, the importer pauses briefly (calculated from Shopify's restore rate) before continuing.

docs/content/en/CMS/webhooks.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,5 @@ StatamicRadPack\Shopify\Events\OrderCreate
120120
```
121121

122122
Each event has one property `$data` with the payload data decoded to a `stdClass`.
123+
124+
For import job failures, see `ProductImportFailed` documented in [Importing Data](/CMS/importing-data#import-failures).

src/Events/ProductImportFailed.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace StatamicRadPack\Shopify\Events;
4+
5+
use Illuminate\Foundation\Events\Dispatchable;
6+
use Illuminate\Queue\SerializesModels;
7+
use Throwable;
8+
9+
class ProductImportFailed
10+
{
11+
use Dispatchable, SerializesModels;
12+
13+
public function __construct(
14+
public int $productId,
15+
public ?string $storeHandle,
16+
public Throwable $exception,
17+
) {}
18+
}

src/Jobs/ImportSingleProductJob.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace StatamicRadPack\Shopify\Jobs;
44

55
use Carbon\Carbon;
6+
use Throwable;
67
use Illuminate\Bus\Queueable;
78
use Illuminate\Contracts\Queue\ShouldQueue;
89
use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,6 +15,7 @@
1415
use Statamic\Facades\Term;
1516
use Statamic\Support\Arr;
1617
use Statamic\Support\Str;
18+
use StatamicRadPack\Shopify\Events\ProductImportFailed;
1719
use StatamicRadPack\Shopify\Support\StoreConfig;
1820
use StatamicRadPack\Shopify\Traits\SavesImagesAndMetafields;
1921
use StatamicRadPack\Shopify\Traits\ThrottlesShopifyRequests;
@@ -700,6 +702,21 @@ private function removeOldVariants(array $variants, string $productSlug)
700702
});
701703
}
702704

705+
/**
706+
* Handle a job failure — log the error and fire an event.
707+
*/
708+
public function failed(Throwable $exception): void
709+
{
710+
$context = array_filter([
711+
'product_id' => $this->productId,
712+
'store' => $this->storeHandle,
713+
]);
714+
715+
Log::error('Shopify: ImportSingleProductJob failed for product '.$this->productId.': '.$exception->getMessage(), $context);
716+
717+
ProductImportFailed::dispatch($this->productId, $this->storeHandle, $exception);
718+
}
719+
703720
/**
704721
* Update the purchase history for this item
705722
*/

src/ServiceProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ class ServiceProvider extends AddonServiceProvider
1818
protected $publishAfterInstall = false;
1919

2020
protected $commands = [
21+
Commands\ShopifyImportCollections::class,
2122
Commands\ShopifyImportProducts::class,
2223
Commands\ShopifyImportSingleProduct::class,
23-
Commands\ShopifyImportCollections::class,
24-
Commands\ShopifyMultistoreEnable::class,
2524
Commands\ShopifyMultistoreDisable::class,
25+
Commands\ShopifyMultistoreEnable::class,
2626
Commands\ShopifyWebhooksRegister::class,
2727
];
2828

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
namespace StatamicRadPack\Shopify\Tests\Unit;
4+
5+
use Illuminate\Support\Facades\Event;
6+
use Illuminate\Support\Facades\Log;
7+
use PHPUnit\Framework\Attributes\Test;
8+
use RuntimeException;
9+
use StatamicRadPack\Shopify\Events\ProductImportFailed;
10+
use StatamicRadPack\Shopify\Jobs\ImportSingleProductJob;
11+
use StatamicRadPack\Shopify\Tests\TestCase;
12+
13+
class ProductImportFailedTest extends TestCase
14+
{
15+
#[Test]
16+
public function logs_error_on_failure()
17+
{
18+
Log::shouldReceive('error')
19+
->once()
20+
->withArgs(function (string $message, array $context) {
21+
return str_contains($message, '12345')
22+
&& $context['product_id'] === 12345;
23+
});
24+
25+
$job = new ImportSingleProductJob(12345);
26+
$job->failed(new RuntimeException('Something went wrong'));
27+
}
28+
29+
#[Test]
30+
public function includes_store_handle_in_log_context_when_present()
31+
{
32+
Log::shouldReceive('error')
33+
->once()
34+
->withArgs(function (string $message, array $context) {
35+
return $context['product_id'] === 12345
36+
&& $context['store'] === 'uk';
37+
});
38+
39+
$job = new ImportSingleProductJob(12345, [], 'uk');
40+
$job->failed(new RuntimeException('Something went wrong'));
41+
}
42+
43+
#[Test]
44+
public function omits_store_from_log_context_in_single_store_mode()
45+
{
46+
Log::shouldReceive('error')
47+
->once()
48+
->withArgs(function (string $message, array $context) {
49+
return ! array_key_exists('store', $context);
50+
});
51+
52+
$job = new ImportSingleProductJob(12345);
53+
$job->failed(new RuntimeException('Something went wrong'));
54+
}
55+
56+
#[Test]
57+
public function fires_product_import_failed_event()
58+
{
59+
Event::fake();
60+
Log::shouldReceive('error')->once();
61+
62+
$exception = new RuntimeException('Something went wrong');
63+
64+
$job = new ImportSingleProductJob(12345);
65+
$job->failed($exception);
66+
67+
Event::assertDispatched(ProductImportFailed::class, function ($event) use ($exception) {
68+
return $event->productId === 12345
69+
&& $event->storeHandle === null
70+
&& $event->exception === $exception;
71+
});
72+
}
73+
74+
#[Test]
75+
public function event_carries_store_handle_in_multi_store_mode()
76+
{
77+
Event::fake();
78+
Log::shouldReceive('error')->once();
79+
80+
$job = new ImportSingleProductJob(99, [], 'uk');
81+
$job->failed(new RuntimeException('Timeout'));
82+
83+
Event::assertDispatched(ProductImportFailed::class, function ($event) {
84+
return $event->productId === 99
85+
&& $event->storeHandle === 'uk';
86+
});
87+
}
88+
}

0 commit comments

Comments
 (0)