> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Asset logos monitoring

# Asset logos — post-release monitoring

The logo pipeline is fail-soft everywhere: a missing image always degrades to a
letter/icon avatar. That means **nothing user-visible breaks when it fails — and
nothing tells you either**, unless you look here. This is the "is it working?"
checklist.

## 1. Did the release activate? (check within an hour)

The single most important signal. If prod's `FINNHUB_PROFILE_API_KEY` is missing
or plan-gated, the backfill safely skips forever and logos never appear.

**Sentry:** `area:asset_logos` + `logo_signal:canary_failed`
→ **Any event means the backfill is doing nothing.** Fix the key, then it
self-heals on the next cron run. Recommended: alert on first occurrence.

**Or curl the cron directly** (response now carries pipeline health):

```bash theme={null}
curl -s "$APP_URL/api/cron/fetch-real-time-stock-prices" \
  -H "Authorization: Bearer $CRON_SECRET" | jq '.results |
  {logoBackfillEnabled, logoBackfillStopped, logosAttempted, logosResolved, logoBacklogRemaining}'
```

Interpret in this order:

1. `logoBackfillStopped: "budget"` → the run ran out of route budget for the
   profile phase. **Not** a key problem — the price writes still landed and the
   next run will pick up logos. Investigate only if it fires two runs in a row.
2. `logoBackfillEnabled: false` **and** `logoBackfillStopped: null` → **key
   problem.** The canary ran and returned no logo, so `FINNHUB_PROFILE_API_KEY`
   is plan-gated or misconfigured. Same failure that fires
   `logo_signal:canary_failed` in Sentry.
3. `logosResolved: 0` with a non-zero `logosAttempted` → the fetch or write
   path is broken. **Plan-gated (403) responses and unknown-symbol responses
   DO count as resolved** — the cron writes the permanent `''` sentinel for
   both, so a definitive "no logo exists here" outcome is a resolution, not a
   miss. The remaining ways `logosResolved` can stay at 0 while attempts are
   non-zero are: every profile fetch threw before landing a write (Finnhub
   5xx/timeouts, rate-limit exhaustion) or every DB write returned an error.
   A race in which every candidate was resolved by another writer between the
   queue snapshot and the update is theoretically possible but essentially
   never happens — this cron is the only writer of logos at scale.

## 2. Is the backfill draining? (first \~2 market days)

`logoBacklogRemaining` should fall by up to \~250 per run and settle near 0.
Coverage query:

```sql theme={null}
SELECT count(*) FILTER (WHERE icon_url IS NULL)          AS awaiting,
       count(*) FILTER (WHERE icon_url = '')             AS resolved_no_logo,
       count(*) FILTER (WHERE icon_url LIKE 'https://%') AS has_logo,
       count(*) FILTER (WHERE industry IS NOT NULL)      AS has_profile
FROM tracked_tickers_latest
WHERE asset_class IS NULL OR asset_class IN ('stock','etf');
```

Crypto equivalent: `SELECT count(*) FILTER (WHERE image_url IS NULL) FROM tracked_tokens WHERE token_id IS NOT NULL;`

**Sentry:** `logo_signal:backfill_stalled` / `token_backfill_stalled` fires only
when a run attempted work and resolved none — it self-gates, so a fully-drained
table (the steady state) never alerts.

Tokens CoinGecko never returns (delisted coins) are retired after
`IMAGE_LOOKUP_MAX_ATTEMPTS` omissions: `image_lookup_attempts` counts them and
the last strike writes the `''` sentinel. Without that they were re-requested
every 2 minutes forever and kept this alert permanently lit. Retired tokens
count as progress in the stall check — a run that only retires stragglers is
finishing work, not failing.

Retirement is a conditional update guarded on `image_url IS NULL`, because the
`''` sentinel is permanent: if a concurrent run resolved a real URL for the
same coin, that write wins and the retirement matches no row.

**Stock logo order:** the per-run logo queue is chosen up front from the whole
candidate set, legacy `api.polygon.io` rows first, then capped at
`LOGO_FETCHES_PER_RUN`. Those legacy rows are the mainstream tickers by
construction (written when the registry only held popular names — it's why
AAPL and KO sat in that bucket), so household names land in the first runs.

The queue is deliberately built *before* the price loops split tickers into
bulk (`/quote/us`) and individual partitions. The bulk partition runs to
completion first, so choosing logo candidates during iteration would let a
legacy ticker missing from the bulk response queue behind every bulk ticker
and miss the budget. Ordering only — every ticker is still priced each run.

## 3. Are images actually rendering for users?

Two independent signals, because the DB can look healthy while screens don't:

**PostHog `portfolio_logo_coverage`** — fired once per portfolio view with
`rows_total` / `rows_with_image`. **This is the headline metric**: the share of
rows that actually show artwork, as users experience it. Chart
`sum(rows_with_image) / sum(rows_total)` over time; it should climb during the
backfill and then sit flat. A sudden drop means the UI stopped rendering what
the DB has — a field-mapping or sanitizer regression — and it will show up here
before anyone files a ticket.

**PostHog `portfolio_asset_logo_failed`** — sampled at 10%, with `logo_host`.
A spike concentrated on one host = that CDN is down, or a CSP/remotePatterns
regression. Baseline is a low trickle (dead URLs in the long tail).

**Sentry `logo_signal:host_not_allowlisted`** — a stored URL was refused
because its host isn't allowlisted. This is the pipeline's most dangerous
silent failure: if a provider moves CDN host (CoinGecko has done this), every
logo vanishes at once and no image error fires, because the image never loads.
Deduped per host, so one event = one new unknown host. **Alert on any event.**

This alert has already paid for itself. On 2026-07-28 it surfaced
`maps.googleapis.com` — 2,941 `properties.photo_url` rows (\~44% of all stored
property artwork: Street View and static-map images written by the enrichment
pipeline since 2025-03) that had never been allowlisted, so every one rendered a
house icon. Nothing else reported it: a blocked image never loads, so no
`onError` fires and `portfolio_asset_logo_failed` stays flat.

**Adding a host means all three layers**, or it still won't render:

1. `ALLOWED_LOGO_HOSTS` / `ALLOWED_LOGO_HOST_SUFFIXES` in `lib/market-data/logo-url.ts`
2. `images.remotePatterns` in `next.config.mjs` — next/image throws otherwise
3. `img-src` in `lib/content-security-policy.ts`

Prefer the exact-host list over a suffix unless the provider genuinely shards
across subdomains. `*.googleapis.com` would also have covered
`storage.googleapis.com` — every public GCS bucket on the internet — turning a
stored URL into an arbitrary-image render.

## 4. Are connected holdings getting registered?

Ticker registration is what gives SnapTrade/Plaid holdings a logo at all.

**Sentry `logo_signal:registration_failed`** (tagged `provider`) fires when a
sync tried to register 3+ new symbols and *every* one failed — meaning new
connected holdings will never get logos, prices or metadata. Partial failures
are normal (delisted tickers, broker-internal ids) and stay silent.

This alert only means something because of the negative cache. Symbols Finnhub
can't resolve — overwhelmingly Canadian TSX/TSXV listings arriving from Plaid
without an exchange suffix (`AC` Air Canada, `ATD` Couche-Tard) — are recorded
in `ticker_registration_failures` and skipped after
`MAX_REGISTRATION_ATTEMPTS`. Before that, \~10k such symbols were retried on
every sync and this alert fired continuously. The skip expires after
`REGISTRATION_RETRY_AFTER_DAYS`, so a Finnhub plan change heals itself.

To see what's parked and why:

```sql theme={null}
SELECT symbol, attempts, last_error, last_failed_at
FROM ticker_registration_failures
ORDER BY attempts DESC, last_failed_at DESC
LIMIT 50;
```

A sudden influx of *US* symbols in that table is the real warning sign — it
means resolution is broken rather than plan-gated.

For detail, Vercel logs still carry
`Ticker registration: checked=… registered=… failed=…`.

## 5. Missing badge artwork (product signal, not a failure)

**Sentry:** `area:asset_logos` is *not* used for this one — vehicle badges
report as `Vehicle make missing badge logo: <make>`, fingerprinted per make, so
each brand is one issue whose **event count ranks demand** for new artwork. Same
idea applies if we later instrument fund issuers.

## 6. Is the page itself being used? (experiment health)

The portfolio page is behind `portfolio-page-v01`, and every interaction now
emits an event — use these to spot a broken surface before it's reported:

| Event | Watch for |
| - | - |
| `portfolio_filter_changed` | Sudden zero = the tabs stopped working |
| `portfolio_search_performed` | Rising `found_results:false` rate = search regressed |
| `portfolio_section_toggled` / `_paged` / `_view_all_clicked` | Zero after a release = section chrome broken |
| `portfolio_empty_picker_type_selected` | Should be followed by `asset_type_selected` / `liability_type_selected`; a gap means tiles no longer open their dialog |

That last one is the most useful funnel on the page: **picker tile clicked →
type dialog opened**. A drop there means new users can't add anything, which is
the worst possible failure and otherwise invisible.

## Recovering `''` sentinels after a Finnhub plan change

The `''` sentinel means "resolved, no logo exists" and is never re-asked. That is
correct until our plan changes — on 2026-07-27 the key gained international
coverage, so listings previously written off as plan-gated became resolvable.

**Reset only suffixed listings.** Verified on that date: `005930.KS`, `1300.HK`,
`19MW.MI`, `1GEV.MI` (plus `VOD.L`, `NESN.SW`, `SAP.DE`, `7203.T`, `SHOP.TO`)
all now return a logo, while unsuffixed sentinels (`AAA`, `AAAU`, `AAPW`,
`ABLS`, `ABNY`) are still genuinely empty — they are ETFs/ETNs misfiled as
`stock` in the registry. Resetting those would spend the throttled profile
budget re-deriving the same sentinel.

```sql theme={null}
-- ~370 rows as of 2026-07-27. The backfill picks them up on its next runs.
-- Allow-list the class rather than excluding 'etf': today the suffixed bucket
-- holds only stock and etf, so the two are equivalent, but a bond or mutual
-- fund landing here later would be reset and re-derive the same sentinel —
-- Finnhub returns nothing for either. NULL counts as stock-like, matching
-- needsLogo in the price cron.
UPDATE tracked_tickers_latest
SET icon_url = NULL
WHERE icon_url = ''
  AND symbol LIKE '%.%'
  AND coalesce(asset_class, 'stock') = 'stock';
```

Before running a reset after any future plan change, spot-check \~5 symbols from
the bucket against `/stock/profile` first. A reset that re-derives the same
sentinel is pure cost on a rate-limited key.

## Known-good "no logo" cases (do not chase)

* Mutual funds / ETFs have no *company* logo — they get an issuer badge, or a
  letter if the issuer isn't in the pack.
* Non-US listings (`.L`, `.SA`) are plan-gated on profile2 → `''` sentinel.
* Delisted tickers, 529 plan IDs, CUSIP-style bond identifiers — no logo exists.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.