Skip to content

Writing a lens ​

A lens records something into its own section of the capture. Write one when you want Monocle to show something it does not record on its own - calls to a payment provider, a search index, an internal service.

A lens ​

php
namespace App\Monocle;

use ExeQue\Monocle\Contracts\Lens;
use ExeQue\Monocle\Eyepiece;
use Illuminate\Contracts\Foundation\Application;

final class StripeLens implements Lens
{
    public function key(): string
    {
        return 'stripe';
    }

    public function description(): string
    {
        return 'Stripe API calls: endpoint, HTTP status, duration and the Stripe error code for failed calls.';
    }

    public function boot(Application $app, Eyepiece $eyepiece): void
    {
        $eyepiece->listen(StripeCallCompleted::class, function (StripeCallCompleted $event) use ($eyepiece): void {
            $eyepiece->push('stripe', [
                'endpoint' => $event->endpoint,
                'status' => $event->status,
                'duration' => $event->milliseconds,
                'error' => $event->errorCode,
            ]);
        });
    }
}
MethodPurpose
key()The name of the section, e.g. monocle-rows section=stripe.
description()Tells the agent what the section contains. Mention the fields that matter.
boot()Optional. Called once when Monocle starts, if the lens has it. Listen to events or hook in however you need to. Its parameters are resolved from the container, like the constructor's - take what you need in either.

Register it in config/monocle.php:

php
'lenses' => [
    // ...
    App\Monocle\StripeLens::class => true,
],

Or from a service provider's register() method:

php
Monocle::register(App\Monocle\StripeLens::class);

Monocle::register(), enable() and disable() must run in register(): once the lenses have booted they throw LensAlreadyBootedException.

Recording ​

php
$eyepiece->push('stripe', ['endpoint' => '/charges', 'duration' => 120.4]);   // add a row
$eyepiece->set('stripe_account', ['id' => 'acct_123']);                       // set key/value data
$eyepiece->span('stripe charge', 'stripe')->start();                          // a span in the timeline
$eyepiece->listen(SomeEvent::class, fn (SomeEvent $event) => ...);            // listen to an event

push() adds the time the row started (rows with a duration are dated back by it) and the origin (the line in your code that caused it) for you.

What the Eyepiece offers a lens:

MethodWhat it does
push(string $section, array $row, bool $origin = true)Adds a row to a section. Pass origin: false to leave the origin out; a row that already has a time or origin keeps its own.
set(string $section, array $data)Merges key/value data into a section.
span(?string $name = null, string $kind = 'user', ?string $key = null)A span for the timeline; call start() and stop() on it. The kind is the second argument here, while Monocle::span($name, $key) takes the key second. The span also has meta(), color(), parent(), run(fn () => ...) (start, run the callback, stop) and duration(); see Spans and metadata.
listen(string $event, Closure $handler)Listens to an event.
recording()Whether a capture is being recorded right now.
now()Milliseconds since the capture started.
relative(float $microtime)A microtime(true) value as milliseconds since the capture started.
currentSpan()The innermost running span, or null.
meta(array $meta)Adds metadata to the capture.
describe(?string $name = null, ?string $status = null, ?string $label = null)Sets the capture's name, status or label.
modify(string $section, Closure $modifier)Changes every row pushed into a section before it is kept, also rows other lenses push. Return the row. Data from set() is not passed through it.
active()The capture being recorded, or null.
wrap(fn (...) => ...)Returns your handler for a hook outside Laravel's events (a Livewire hook, Gate::after(), a validator's after(), ...), so its time counts as your lens's like a listen() handler.
outside(fn () => ...)Runs application code from your lens, such as a closure you resolve to time it, and returns its result. Monocle's own time per lens is shown on every capture; what runs here is not counted as yours.
discard()Drops the capture being recorded, so nothing is stored.
start(CaptureType $type), finalize()Start and store a capture yourself; see Testing.
origin()The line in your code that is running now, to keep and put on a row you push later.
masker(?string $lens = null)Masks values the way the masking configuration says, including entries keyed by your lens class: headers(), cookies(), inputs() and body($body, $contentType).
config(string $key, mixed $default = null)Reads a value from config/monocle.php.

Methods that record or change something - push(), set(), meta(), listen(), modify() and the like - return the eyepiece, so the calls chain.

An exception thrown inside a listen() handler, or while pushing, setting or timing a span, never reaches your application: it is logged and shown as a warning on the capture. Exceptions thrown in boot() itself, or in code you put in place with $app->extend() (see Timing a client you call), are not caught. While you work on a lens, set MONOCLE_THROW_FAILURES=true to have the caught ones thrown instead.

Options ​

Implement Configurable to receive options from the config:

php
use ExeQue\Monocle\Contracts\Lenses\Configurable;

final class StripeLens implements Configurable, Lens
{
    private int $slowMs = 500;

    public function configure(array $options): void
    {
        $this->slowMs = (int) ($options['slow_ms'] ?? $this->slowMs);
    }
}
php
App\Monocle\StripeLens::class => ['slow_ms' => 300],

configure() is only called when the lens is listed with an array of options. With => true it is not called, so give every option its default on the property.

Totals and warnings ​

Implement Summarizable to add to the summary in monocle-show and on the dashboard's overview:

php
use ExeQue\Monocle\Contracts\Lenses\Summarizable;
use ExeQue\Monocle\Data\Capture;
use ExeQue\Monocle\Data\Summary;
use ExeQue\Monocle\Enums\Unit;

public function summarize(Capture $capture): Summary
{
    $rows = $capture->rows('stripe');
    $failed = array_filter($rows, fn (array $row): bool => $row['error'] !== null);

    return Summary::make()
        ->total('stripe_calls', count($rows), 'calls')
        ->total('stripe_ms', array_sum(array_column($rows, 'duration')), 'spent', Unit::Milliseconds)
        ->total('stripe_failed', count($failed), 'failed')
        ->when($failed !== [])
        ->error(count($failed).' Stripe call(s) failed.');
}

summarize() runs every time the summary is read - on the dashboard, through MCP and from artisan - possibly long after the capture was recorded. Work from $capture alone, not from anything the lens kept while recording.

A summary is immutable: start with Summary::make(), and since every method returns a new summary, chain the calls or assign the result. when() makes the next call conditional, as above. Methods that take an array (totals(), warnings(), errors()) take a collection too, as do those of Presentation, Card and Formatted.

MethodWhat it adds
total($name, $value, $label = null, $unit = null)One total. A null value throws InvalidSummaryTotalException.
totals([$name => $value, ...], $unit = null)Several totals in the same unit, without labels. null values are kept.
headline($name, $value, $label = null, $unit = null)A total about the whole capture, shown at the top of the overview next to the duration and peak memory instead of under your tab.
warning($message), warnings([...])Things worth a look.
error($message), errors([...])Things that failed, such as a call or a job.
tag($label, $tone = 'muted')A short label for the whole capture, shown next to its name in the dashboard header and in the list. $tone is muted, ok, warn or bad.

The label says what the value is ("failed", "spent"). Without one the overview shows the name, humanised and without its unit suffix (stripe_ms becomes "stripe"). The unit decides how the value is shown:

UnitShown as
Unit::Milliseconds8.7 ms
Unit::Bytes26 B, 11 KB, 1.2 MB
Unit::Megabytes4 MB
Unit::Percent66.7 %
any string, e.g. 'DKK'the value followed by the string: 49 shows as 49 DKK

A string unit does not format the number; pass '49.00' to show 49.00 DKK.

On the overview your totals are grouped under the name of your tab, in tab order; clicking the group's name opens the tab. Totals that are null, 0 or an array are left out of the overview; arrays are still in the summary through MCP and artisan. A total whose name ends in failed, errors, exceptions, denied, declined or warnings shows in red when it is above 0.

Errors are listed before warnings, mark the capture in the dashboard's list and count for its "only errors" filter and for has_errors in monocle-list. On your tab's count, errors show red and warnings yellow; see Warnings and errors.

Advice for agents ​

The Monocle skill lists every lens with one sentence on when to look at it. Implement Advises to write that sentence yourself; otherwise the first sentence of description() is used.

php
use ExeQue\Monocle\Contracts\Lenses\Advises;

final class PaymentsLens implements Advises, Lens
{
    public function advice(): string
    {
        return 'Look here when a checkout fails or is slow: every call to the payment provider.';
    }
}

More than one section ​

A lens that writes sections besides its own key() lists them with ProvidesSections::sections(). Listing the lens in dashboard.tabs then places those tabs right after its own:

php
use ExeQue\Monocle\Contracts\Lenses\ProvidesSections;

final class StripeLens implements Lens, ProvidesSections
{
    public function key(): string
    {
        return 'stripe';
    }

    public function sections(): array
    {
        return ['stripe_webhooks'];
    }

    // ...
}

Before and after each capture ​

  • Finalizable::finalize(Capture $capture) - runs just before a capture is saved.
  • Resettable::reset() - runs after each capture, to clear anything you kept between events.

Lenses for other packages ​

A lens that records another package implements RequiresPackages and names the Composer packages it needs. Unless all of them are installed, the lens is left out entirely - it does not boot, gets no tab and adds nothing to the summary - so it can be listed in the configuration of applications that do not use the package.

php
use ExeQue\Monocle\Contracts\Lenses\RequiresPackages;

final class StripeLens implements Lens, RequiresPackages
{
    public static function packages(): array
    {
        return ['stripe/stripe-php'];
    }
}

Use the package's Composer name, as in its composer.json. A capture (FormatsCapture, see Extending the request lens) for another package implements RequiresPackages too.

Adding to another lens ​

A lens can add to another one instead of having a section of its own - the way the impersonation packages add to the auth section. Implement ExtendsLens and name the lens you add to. Your lens then runs only while that lens is enabled, its totals, warnings and errors count on that lens's tab, and its description is added to that lens's description for agents.

Push rows into that lens's section, and use modify() to add to the rows it writes itself:

php
use ExeQue\Monocle\Contracts\Lens;
use ExeQue\Monocle\Contracts\Lenses\ExtendsLens;
use ExeQue\Monocle\Contracts\Lenses\RequiresPackages;
use ExeQue\Monocle\Eyepiece;
use ExeQue\Monocle\Lenses\AuthLens;

final class TeamsLens implements ExtendsLens, Lens, RequiresPackages
{
    public function __construct(
        private readonly Eyepiece $eyepiece,
    ) {
    }

    public function key(): string
    {
        return 'auth_teams';
    }

    public function description(): string
    {
        return 'With acme/teams: team_switched rows, and the current team on the authenticated row.';
    }

    public static function extends(): string
    {
        return AuthLens::class;
    }

    public static function packages(): array
    {
        return ['acme/teams'];
    }

    public function boot(): void
    {
        $this->eyepiece->listen(TeamSwitched::class, fn (TeamSwitched $event) => $this->eyepiece->push('auth', [
            'event' => 'team_switched',
            'team' => $event->team->name,
        ]));

        // Every row the auth lens writes for the authenticated user gets the current team.
        $this->eyepiece->modify('auth', fn (array $row): array => ($row['event'] ?? null) === 'authenticated'
            ? [...$row, 'team' => session('team')]
            : $row);
    }
}

An extension still has a key(), but it only names the extension: its rows go into the section of the lens it extends. Its description() is added to that section's description, and it does not need Advises.

Impersonation ​

For a package that lets one user act as another, extend ImpersonationLens. It already adds to the auth lens; name the package, the events fired when an impersonation starts and ends (each with $impersonator and $impersonated) and how to tell, from the session, who is impersonating now:

php
use ExeQue\Monocle\Lenses\Auth\ImpersonationLens;

final class SwitchUserLens extends ImpersonationLens
{
    public function key(): string
    {
        return 'auth_switch_user';
    }

    public static function packages(): array
    {
        return ['acme/switch-user'];
    }

    protected function startedEvent(): string
    {
        return SwitchedToUser::class;
    }

    protected function endedEvent(): string
    {
        return SwitchedBack::class;
    }

    protected function impersonator(): ?array
    {
        return session()->has('switched_from')
            ? ['id' => session('switched_from'), 'guard' => 'web']
            : null;
    }
}

The auth section then gets a row when an impersonation starts and ends, the authenticated user is marked with who is impersonating them, and the summary shows it and tags the capture "impersonated by ...", as with the built-in impersonation packages.

Extending the request lens ​

The request lens keeps a response's body when it has a capture for it, and shows it on the Request tab as cards next to the raw body. A capture can also show fields of the request's payload in a format. Write your own capture for requests and responses that read better another way - an API error format, a GraphQL call, an export.

php
namespace App\Monocle;

use ExeQue\Monocle\Contracts\FormatsCapture;
use ExeQue\Monocle\Data\Card;
use ExeQue\Monocle\Data\Formatted;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final class ProblemCapture implements FormatsCapture
{
    public function format(Request $request, Response $response): Formatted
    {
        $problem = json_decode((string) $response->getContent(), true);

        if (! is_array($problem) || ! isset($problem['title'])) {
            return Formatted::make();
        }

        return Formatted::make()->card(
            Card::titled($problem['title'])
                ->subtitle($problem['type'] ?? null)
                ->items([
                    'status' => $problem['status'] ?? $response->getStatusCode(),
                    'detail' => $problem['detail'] ?? null,
                    'errors' => $problem['errors'] ?? [],
                ]),
        );
    }
}

format() returns a Formatted, built fluently from Formatted::make(). A side you leave alone is kept as it is:

MethodSideWhat it does
card(Card $card)responseadds a card; without cards only the raw body is kept
field(string $key, string $format, ?string $text = null)requestshows a payload field (dotted key, such as query or 0.query) in a format, optionally as this text, such as the value printed neatly
replace(string $key, mixed $value)requestrecords this value for a payload field instead of the one sent, such as the value with secrets inside it masked

A Card is built the same way, from Card::titled($title):

MethodWhat it does
subtitle(?string $subtitle)the line next to the title
item(string $name, mixed $value, ?string $format = null)one item, optionally shown in a format
items(array $items)more items, after the ones the card has

Items can be text, numbers, lists and maps. A list of field/from/to entries shows as a table of changes, and a list of other objects as a table. The format graphql colours a GraphQL document; it is the only format so far.

A payload field that input masking changes keeps its masked value and gets no format, so its text cannot show what the mask hides.

Captures are resolved from the container, so the constructor can take what it needs. Card values are masked like request input; mask secrets inside a field's value yourself with replace(). A capture for another package can implement RequiresPackages; when the package is not installed the capture is skipped and the next matching entry is used. The HTTP client lens uses the same captures for response content types, under its own capture.response_types; there the request side formats the call's payload.

Register the capture in config/monocle.php for a route name, a response header or a content type (wildcards allowed in route names and content types):

php
RequestLens::class => [
    'capture' => [
        'routes' => [
            'api.orders.*' => OrderCapture::class,
        ],
        'headers' => [
            'X-Export' => ExportCapture::class,
        ],
        'response_types' => [
            'application/problem+json' => ProblemCapture::class,
            'application/json' => JsonCapture::class,
        ],
    ],
],

A route capture wins over a header capture, which wins over a content type capture. Only responses with a capture are kept; other responses only have their size.

Examples ​

Timing a client you call ​

When the code you want to see has no events, wrap it. Replace the binding with a version that times each call: a span on the timeline and a row in the section.

php
namespace App\Monocle;

use App\Search\SearchClient;
use ExeQue\Monocle\Contracts\Lens;
use ExeQue\Monocle\Eyepiece;
use Illuminate\Contracts\Foundation\Application;

final class SearchLens implements Lens
{
    public function key(): string
    {
        return 'search';
    }

    public function description(): string
    {
        return 'Search queries: index, query, number of hits and duration in ms.';
    }

    public function boot(Application $app, Eyepiece $eyepiece): void
    {
        $app->extend(SearchClient::class, fn (SearchClient $client): SearchClient => new TimedSearchClient($client, $eyepiece));
    }
}
php
namespace App\Monocle;

use App\Search\SearchClient;
use ExeQue\Monocle\Eyepiece;

final class TimedSearchClient implements SearchClient
{
    public function __construct(private SearchClient $inner, private Eyepiece $eyepiece)
    {
    }

    public function search(string $index, string $query): array
    {
        $span = $this->eyepiece->span("search {$index}", 'search')->start(['query' => $query]);
        $hits = $this->inner->search($index, $query);
        $span->stop(['hits' => count($hits)]);

        $this->eyepiece->push('search', [
            'index' => $index,
            'query' => $query,
            'hits' => count($hits),
            'duration' => $span->duration(),
        ]);

        return $hits;
    }
}

Facts about the capture ​

set() stores key/value data instead of rows - useful for facts that hold for the whole capture, like the tenant a request ran for.

php
public function boot(Application $app, Eyepiece $eyepiece): void
{
    $eyepiece->listen(TenancyInitialized::class, function (TenancyInitialized $event) use ($eyepiece): void {
        $eyepiece->set('tenant', [
            'id' => $event->tenant->getTenantKey(),
            'domain' => $event->tenant->domain,
        ]);
    });
}

monocle-show sections=["tenant"] returns it. On the dashboard such a section only gets a tab when the lens has a tab view; see Your own tab.

Collecting rows and warning about them ​

A lens can keep rows itself and write them when the capture ends. This one lists translation keys that were missing in the request, once per key with how often it was used, and warns about them.

php
namespace App\Monocle;

use ExeQue\Monocle\Contracts\Lens;
use ExeQue\Monocle\Contracts\Lenses\Finalizable;
use ExeQue\Monocle\Contracts\Lenses\Resettable;
use ExeQue\Monocle\Contracts\Lenses\Summarizable;
use ExeQue\Monocle\Data\Capture;
use ExeQue\Monocle\Data\Summary;
use ExeQue\Monocle\Eyepiece;
use Illuminate\Contracts\Foundation\Application;

final class MissingTranslationsLens implements Finalizable, Lens, Resettable, Summarizable
{
    /** @var array<string, array{key: string, locale: string, count: int}> */
    private array $missing = [];

    private Eyepiece $eyepiece;

    public function key(): string
    {
        return 'missing_translations';
    }

    public function description(): string
    {
        return 'Translation keys that had no translation: key, locale and how often it was used.';
    }

    public function boot(Application $app, Eyepiece $eyepiece): void
    {
        $this->eyepiece = $eyepiece;

        $app['translator']->handleMissingKeysUsing(function (string $key, array $replace, ?string $locale) {
            $id = $locale.'|'.$key;
            $this->missing[$id] ??= ['key' => $key, 'locale' => (string) $locale, 'count' => 0];
            $this->missing[$id]['count']++;

            return $key;
        });
    }

    public function finalize(Capture $capture): void
    {
        foreach ($this->missing as $row) {
            $this->eyepiece->push('missing_translations', $row, origin: false);
        }
    }

    public function reset(): void
    {
        $this->missing = [];
    }

    public function summarize(Capture $capture): Summary
    {
        $rows = $capture->rows('missing_translations');
        return Summary::make()
            ->total('missing_translations', count($rows), 'missing')
            ->when($rows !== [])
            ->warning(count($rows).' translation key(s) have no translation, e.g. '.($rows[0]['key'] ?? '').'.');
    }
}

This replaces a missing-key callback the application already set; call that one from inside yours if you have it.

Your own tab ​

A section gets a tab in the dashboard when it has rows (from push()), or when it has data (from set()) and its lens has a tab view of its own (see tab() below). Data from set() without a tab view gets no tab; it is still available through MCP and artisan.

Without anything else the tab is a table worked out from the rows. To decide how yours looks, implement Presentable and describe it:

php
use ExeQue\Monocle\Contracts\Lenses\Presentable;
use ExeQue\Monocle\Dashboard\Presentation;

final class PaymentsLens implements Lens, Presentable
{
    public function presentation(): Presentation
    {
        return Presentation::table()
            ->label('Payments')
            ->columns(['customer', 'amount' => 'Amount', 'currency', 'status'])
            ->detailsOnly(['card', 'decline_reason'])
            ->groupBy(['customer' => 'by customer', 'status' => 'by outcome'])
            ->format(['status' => 'status', 'refunded' => ['flag', 'refunded', null, 'bad', 'warn']])
            ->highlight(['declined' => 'bad'])
            ->details('monocle.payment');
    }
}

Presentation::table() (or Presentation::make()) starts an empty presentation. Every method returns a new one, so chain the calls or assign the result.

MethodWhat it does
label()the tab's name
columns()the table's columns, in order: a list of fields, or field => header
detailsOnly()fields only shown when a row is expanded
groupBy()fields the rows can be grouped by, with the label of their chip
details()a Blade view rendering an expanded row, instead of the list of its fields
tab()a Blade view for the whole tab, instead of the table
includes()other sections to show on this tab
format()how fields show in the table; see below
highlight()tints rows where a field is set: field => 'warn' or 'bad'

The presentation, the tab view, the tab's place and its count belong to the section named by your lens's key(). Rows you push to a section with another name get a plain table of their own.

Tabs are ordered by dashboard.tabs in the configuration, which lists lens classes or section keys. Tabs not listed there, such as your own, follow the listed ones in the order their sections were recorded. Overview and Timeline always come first and Trace last. See Dashboard.

Formatting fields ​

Durations (duration, *_ms), sizes (*_bytes), file paths, exceptions, {field, from, to} changes and long text are formatted wherever they appear. format() picks how other fields show, by name:

FormatShows
'http-status'an HTTP status code, coloured by class; empty means the call failed
'status'a status word, coloured: processed green, failed red, and so on
'level'a log level, coloured by severity
['flag', 'yes', 'no']true or false as a word: true in green, false in red. Add two tones ('ok', 'warn', 'bad', 'muted') to pick the colours of false and true, and pass null to show nothing for one of them
'bool'true in green, false muted
'code'the value in full in a monospace font, such as a command line
['truncate', 80]cut after that many characters, the full text in a tooltip
['prefix', '#']the value after a prefix
['reference', 'repeat of #']a badge pointing at another row
['highlight', ['miss', 'failed'], 'warn']the listed values in a colour
'fields'a key/value map as a short list
'rules'validation rules per field
['capture', 'job_id']a link to the capture this row started, such as a queued job: the newest capture it dispatched whose job_id equals the value, showing that capture's status. Shows pending until there is one

When rows are grouped, a group counts a row as failed when its failed field is true, a flag field shown in red is false or an http-status field is empty or 400 and up. A row inside a group leads with its first http-status, status or level field.

A column that is empty, false or 0 in every row, or true in every row, is left out.

Other sections on your tab ​

includes(['stripe_webhooks']) shows other sections on your tab; give one a presentation of its own to format its table, as in includes(['stripe_webhooks' => Presentation::table()->label('Webhooks')->format([...])]). Their rows are loaded with the tab into tabData.included.stripe_webhooks, and the default table (also <x-monocle::section-table /> in a tab view) shows them as extra tables under yours. While your tab is shown they get no tab of their own, and their warnings and errors count on your tab (their rows do not add to its count).

An expanded row ​

The details view receives $row, the row as your lens recorded it, and $capture. It is rendered inside the dashboard, so the dashboard's colours apply: text-muted, text-ok, text-bad, text-warn and font-mono read like the rest of it.

blade
<div class="grid gap-2">
    <p class="font-semibold">{{ number_format($row['amount'] / 100, 2) }} {{ $row['currency'] }}</p>
    <p class="text-muted">Charged to {{ $row['customer'] }}</p>
    <x-monocle::facts :items="[['Status', $row['status'], $row['status'] === 'failed' ? 'text-bad' : ''], ['Card', $row['card']]]" />
    <x-monocle::origin :origin="$row['origin'] ?? null" />
</div>

These components take PHP values and fit a details view:

ComponentPropsShows
<x-monocle::block>title, copya labelled block, with a copy button for copy when given
<x-monocle::facts>itemsshort facts side by side, each [label, value] or [label, value, class]; empty values are left out
<x-monocle::pairs>pairs, what[key, value] pairs as a table with a copy button per value
<x-monocle::name>value, class, fulla class name or path, shortened unless full, linked to your editor
<x-monocle::exception>exceptiona recorded exception and the ones it wraps
<x-monocle::origin>originwhere the row came from and the call chain

A tab view ​

A tab view is part of the dashboard page and receives $section, the section key. Wrap it in a panel, and put the content in a template that only exists while your tab is open, so tabData belongs to your tab:

blade
<x-monocle::panel name="payments">
    <template x-if="tab === 'payments'">
        <div class="grid gap-4">
            <x-monocle::section title="Charges">
                <x-monocle::section-table />
            </x-monocle::section>
            <p :class="cls.empty" x-show="! tabLoading && ! tabData?.rows?.length">No charges.</p>
        </div>
    </template>
</x-monocle::panel>

The dashboard's Alpine state a tab view can read:

NameHolds
tabthe key of the open tab
capturethe open capture
summaryits summary
tabDatathe open tab's data: rows, total, next_offset, data for a section filled with set(), and included
tabLoadingtrue while the tab's data loads
clsthe dashboard's class sets, such as cls.sectionTitle, cls.tableWrap, cls.th, cls.td and cls.empty

These components take Alpine expressions and fit a tab view:

ComponentPropsShows
<x-monocle::panel>name, showthe tab's panel, shown while the tab named name is open or while show is true
<x-monocle::section>title, showa titled block, shown while show is true when given
<x-monocle::section-table>the usual table of the open tab, with its grouping chips and Show more
<x-monocle::kv-table>rows, keys, valuea key/value table, one row per [key, value] pair in rows, e.g. rows="Object.entries(tabData?.data ?? {})"
<x-monocle::badge>counta rounded count
<x-monocle::chips>options, active, pick, label, leada row of toggle chips
<x-monocle::fold-heading>opena section title that toggles the Alpine property named by open
<x-monocle::collapse>open, htmlcontent that folds open while open is true
<x-monocle::expand-button>open, toggle, label, chevronthe button that folds a row out
<x-monocle::chevron>open, sizea chevron that turns while open is true
<x-monocle::copy>text, or bind, what, what-bind, hovera copy button for text, or for the Alpine expression in bind

Testing ​

Monocle does not record while tests run unless it is enabled, and lenses boot together with the application, so setting monocle.enabled inside a test is too late. Enable it before the application boots. In an application, add it to phpunit.xml:

xml
<php>
    <env name="MONOCLE_ENABLED" value="true"/>
    <env name="MONOCLE_THROW_FAILURES" value="true"/>
</php>

In a package tested with Orchestra Testbench, set it in defineEnvironment(), together with your lens:

php
use App\Monocle\StripeLens;

protected function defineEnvironment($app): void
{
    $app['config']->set('monocle.enabled', true);
    $app['config']->set('monocle.throw_failures', true);
    $app['config']->set('monocle.lenses.'.StripeLens::class, true);
}

Then start a capture, do the work and finalize it:

php
use ExeQue\Monocle\Enums\CaptureType;
use ExeQue\Monocle\Eyepiece;

app(Eyepiece::class)->start(CaptureType::Request);

event(new StripeCallCompleted('/charges', 200, 120.4, null));

$capture = app(Eyepiece::class)->finalize();

expect($capture->rows('stripe'))->toHaveCount(1);

MONOCLE_THROW_FAILURES makes an exception in your lens fail the test instead of being logged.

Sharing your lens ​

Publish the lens as its own package and open an issue to have it listed under Third-party lenses.