Skip to main content

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):
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:
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:
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: 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.
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.