Skip to content

Ticket Tracking

League Container usage

<?php

use League\Container\Container;
use Subscribed\IncidentMonitor\Container\IncidentMonitorServiceProvider;
use Subscribed\IncidentMonitor\Connector\Asana\AsanaConnector;
use Subscribed\IncidentMonitor\Connector\Asana\AsanaClientInterface;
use Subscribed\IncidentMonitor\Connector\Asana\AsanaFieldResolver;
use Subscribed\IncidentMonitor\Connector\Asana\AsanaHttpClient;
use Subscribed\IncidentMonitor\Connector\Asana\FixedFingerprintFieldGidProvider;
use Subscribed\IncidentMonitor\IncidentMonitor;
use Subscribed\IncidentMonitor\Value\TicketRequest;

// Create the application container and register the core service bindings.
$container = new Container();
$container->addServiceProvider(new IncidentMonitorServiceProvider());

// Resolve the Asana custom field used to store the fingerprint.
// This lets the connector look up an existing open ticket before creating a new one.
$fieldResolver = new AsanaFieldResolver(
    $container->get(\Psr\Http\Client\ClientInterface::class),
    $container->get(\Psr\Http\Message\RequestFactoryInterface::class),
    $container->get(\Psr\Http\Message\StreamFactoryInterface::class),
    'asana-personal-access-token',
    $container->get(\Psr\SimpleCache\CacheInterface::class)
);

$fingerprintFieldGid = $fieldResolver->getFieldGidForProject('asana-project-id', 'fingerprint');
$fieldProvider = new FixedFingerprintFieldGidProvider($fingerprintFieldGid);

// Bind the concrete Asana API adapter used by the connector.
$container->add(AsanaClientInterface::class, function () use ($container, $fieldProvider) {
    return new AsanaHttpClient(
        $container->get(\Psr\Http\Client\ClientInterface::class),
        $container->get(\Psr\Http\Message\RequestFactoryInterface::class),
        $container->get(\Psr\Http\Message\StreamFactoryInterface::class),
        'asana-personal-access-token',
        $fieldProvider,
        $container->get(\Psr\SimpleCache\CacheInterface::class)
    );
});

// Register the connector so IncidentMonitor can resolve it by name.
$registry = $container->get(\Subscribed\IncidentMonitor\Connector\ConnectorRegistry::class);
$registry->register(new AsanaConnector($container->get(AsanaClientInterface::class)));

// Resolve the tracker service itself from the container.
$tracker = $container->get(IncidentMonitor::class);

// Build the ticket request. The fingerprint is what makes de-duplication stable.
$request = new TicketRequest(
    'asana-project-id',
    'Checkout failing for EU customers',
    'Spike in 5xx in /checkout. Error code: PAY-42.',
    'asana-user-id',
    ['incident', 'checkout'],
    new DateTimeImmutable('+1 day'),
    [
        'service' => 'payments',
        'environment' => 'production',
        'fingerprint' => 'payments/not-captured/12345',
    ]
);

// Create a new ticket or return an existing matching one.
$result = $tracker->createOrGet('asana', $request);

De-duplication strategy

Default strategy uses a normalized fingerprint, preferring metadata['fingerprint'] when present. If not provided, it falls back to:

projectId + title + description

The fingerprint is used in two layers:

  1. Local fast cache via PSR-16
  2. Connector-level lookup for an existing open ticket

Recommended fingerprint shape:

payments/not-captured/{payment-id}

If you want to enforce explicit fingerprints, bind:

Subscribed\IncidentMonitor\Deduplication\StrictFingerprintDeduplicationStrategy

Asana connector

AsanaConnector expects an adapter implementing AsanaClientInterface.

Minimum adapter responsibilities:

  • findOpenTaskByFingerprint(projectId, fingerprint)
  • createTask(request, fingerprint)

Notes:

  • TicketRequest::tags() is treated as Asana tag GIDs by AsanaHttpClient
  • Open/closed is based on completed=false
  • Fingerprint lookup uses custom_fields.{gid}.value

Bedrock / shared container bootstrap

<?php

use Subscribed\IncidentMonitor\Container\IncidentMonitorBootstrap;
use Subscribed\IncidentMonitor\Facade\IncidentMonitorFacade;

// Register the entire package into the shared container.
IncidentMonitorBootstrap::register($container, [
    // Optional: personal access token for the bundled Asana connector.
    'asana_token' => 'asana-personal-access-token',
    // Map your logical project keys to external Asana project IDs.
    'project_map' => [
        'customer-service' => 'asana-project-id-cs',
        'warehouse' => 'asana-project-id-warehouse',
        'development' => 'asana-project-id-dev',
    ],
    // Used when createDefault() is called or when health checks omit project_key.
    'default_project_key' => 'development',
    // Cache window for local de-duplication results.
    'dedupe_ttl_seconds' => 86400,
]);

// Expose the container to the static facade API.
IncidentMonitorFacade::setContainer($container);

This bootstrap:

  • Adds the service provider
  • Registers the Asana connector when asana_token is configured
  • Sets the strict fingerprint strategy by default

Concurrency lock

The library uses a short-lived per-fingerprint lock to prevent duplicate creates under concurrent load.

Defaults:

  • lock_ttl_seconds: 30
  • lock_wait_ms: 1500
  • lock_wait_interval_ms: 200

If no PSR-16 cache is bound, the bootstrap uses:

  • WordPress transients when available
  • File cache in sys_get_temp_dir() . '/incident-monitor-cache' otherwise

Override with:

'cache_dir' => '/path/to/cache',
'cache_namespace' => 'your-project',

Guzzle 7 wiring

<?php

use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Psr7\HttpFactory;
use Subscribed\IncidentMonitor\Http\IncidentMonitorHttpClientInterface;

// Bind a dedicated HTTP client for IncidentMonitor so it stays isolated from other integrations.
$container->addShared(IncidentMonitorHttpClientInterface::class, function () {
    return new GuzzleClient();
});

// Bind the PSR-17 factories used by the Asana HTTP client.
$container->addShared(\Psr\Http\Message\RequestFactoryInterface::class, function () {
    return new HttpFactory();
});

$container->addShared(\Psr\Http\Message\StreamFactoryInterface::class, function () {
    return new HttpFactory();
});

Facade usage

<?php

use Subscribed\IncidentMonitor\Facade\IncidentMonitorFacade;

// Resolve the external project through the configured project map and create the ticket.
IncidentMonitorFacade::createFor(
    'asana',
    'customer-service',
    'Uncaptured payments detected',
    'Payment 12345 was authorized but not captured.',
    'asana-user-id',
    ['incident', 'payments'],
    null,
    ['fingerprint' => 'payments/not-captured/12345']
);

Safe usage:

<?php

// tryCreate() returns null if the facade has not been bootstrapped yet.
$result = IncidentMonitorFacade::tryCreateFor(
    'asana',
    'customer-service',
    'Uncaptured payments detected',
    'Payment 12345 was authorized but not captured.',
    null,
    [],
    null,
    ['fingerprint' => 'payments/not-captured/12345']
);

Default project usage:

<?php

// createDefault() uses the configured default_project_key from bootstrap config.
IncidentMonitorFacade::createDefaultFor(
    'asana',
    'Warehouse outage',
    'No pick list generated for 15 minutes.',
    null,
    ['incident', 'warehouse'],
    null,
    ['fingerprint' => 'warehouse/pick-list/missing-15m']
);

WP-CLI

The package exposes incident commands through WP-CLI when the package and facade bootstrap are loaded:

wp subscribed incidents create "Daily import failed" "The ERP import failed for shop 42." \
  --connector=asana \
  --project-key=operations \
  --tag=incident \
  --tag=erp-import \
  --fingerprint=erp-import/shop-42/daily-job

This can also be queued instead of executed inline:

wp subscribed incidents queue "Daily import failed" "The ERP import failed for shop 42." \
  --connector=asana \
  --project-key=operations \
  --fingerprint=erp-import/shop-42/daily-job

Issue tracking setup with IncidentMonitorFacade

This is the smallest setup for packages that just want to emit incidents through the facade.

<?php

use League\Container\Container;
use Subscribed\IncidentMonitor\Container\IncidentMonitorBootstrap;
use Subscribed\IncidentMonitor\Facade\IncidentMonitorFacade;

$container = new Container();

// Register connector, cache, lock, and project resolution.
IncidentMonitorBootstrap::register($container, [
    'asana_token' => getenv('ASANA_TOKEN'),
    'project_map' => [
        'development' => 'asana-project-id-dev',
        'operations' => 'asana-project-id-ops',
    ],
    'default_project_key' => 'development',
    'dedupe_ttl_seconds' => 86400,
]);

// Make the facade available to the rest of the application.
IncidentMonitorFacade::setContainer($container);

// Later in your package code, emit an incident through the static facade.
IncidentMonitorFacade::createFor(
    'asana',
    'operations',
    'Daily import failed',
    'The ERP import failed for shop 42.',
    null,
    ['incident', 'erp-import'],
    null,
    [
        // Keep this stable for the same incident so duplicates collapse correctly.
        'fingerprint' => 'erp-import/shop-42/daily-job',
        'shop_id' => 42,
    ]
);