Skip to content

Commit 332257f

Browse files
Merge branch 'master' into responsive-mobile-globe
2 parents a671562 + c851eec commit 332257f

File tree

3 files changed

+193
-30
lines changed

3 files changed

+193
-30
lines changed

.github/workflows/build.yml

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: Build
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
pull_request:
8+
9+
jobs:
10+
build:
11+
name: Build and smoke test
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: read
15+
packages: write
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Set up Docker Buildx
21+
uses: docker/setup-buildx-action@v3
22+
23+
- name: Log in to GHCR
24+
if: github.event_name == 'push'
25+
uses: docker/login-action@v3
26+
with:
27+
registry: ghcr.io
28+
username: ${{ github.actor }}
29+
password: ${{ secrets.GITHUB_TOKEN }}
30+
31+
- name: Build
32+
uses: docker/build-push-action@v6
33+
with:
34+
context: .
35+
# Load into the local Docker daemon for the smoke test.
36+
# On main pushes we also push to GHCR (see next step).
37+
load: true
38+
platforms: linux/amd64
39+
tags: sentry-orbital:local
40+
cache-from: type=registry,ref=ghcr.io/getsentry/sentry-orbital:nightly
41+
cache-to: type=inline
42+
labels: |
43+
org.opencontainers.image.revision=${{ github.sha }}
44+
org.opencontainers.image.source=https://github.com/getsentry/sentry-orbital
45+
46+
- name: Smoke test
47+
run: |
48+
docker run --rm -d \
49+
--name orbital-smoke \
50+
-p 7000:7000 \
51+
sentry-orbital:local
52+
# Wait for the container to be ready
53+
for i in $(seq 1 10); do
54+
if curl -sf http://localhost:7000/healthz; then
55+
break
56+
fi
57+
echo "Waiting... ($i)"
58+
sleep 1
59+
done
60+
# /healthz must return 200 with body "ok"
61+
HEALTH=$(curl -sf http://localhost:7000/healthz)
62+
[ "$HEALTH" = "ok" ] || (echo "healthz returned: $HEALTH" && exit 1)
63+
# / must return 200 and contain the globe canvas
64+
curl -sf http://localhost:7000/ | grep -q 'orbital' || (echo "index page missing expected content" && exit 1)
65+
docker stop orbital-smoke
66+
67+
- name: Push to GHCR
68+
if: github.event_name == 'push'
69+
uses: docker/build-push-action@v6
70+
with:
71+
context: .
72+
push: true
73+
platforms: linux/amd64
74+
tags: |
75+
ghcr.io/getsentry/sentry-orbital:nightly
76+
ghcr.io/getsentry/sentry-orbital:${{ github.sha }}
77+
cache-from: type=registry,ref=ghcr.io/getsentry/sentry-orbital:nightly
78+
cache-to: type=inline
79+
labels: |
80+
org.opencontainers.image.revision=${{ github.sha }}
81+
org.opencontainers.image.source=https://github.com/getsentry/sentry-orbital

main.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,16 @@ func main() {
240240
}
241241
defer conn.Close()
242242
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
243+
// Heartbeat: send a ping every 3s so the client watchdog doesn't
244+
// fire during legitimate low-traffic periods.
245+
go func() {
246+
ticker := time.NewTicker(3 * time.Second)
247+
defer ticker.Stop()
248+
for range ticker.C {
249+
es.SendEventMessage([]byte("{}"))
250+
}
251+
}()
252+
243253
go func() {
244254
b := make([]byte, 256)
245255
for {

static/orbital.js

Lines changed: 102 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,32 @@ function addFeedItem(platform, lat, lng) {
386386
let windowInFocus = !document.hidden;
387387
let source = null;
388388

389+
// Watchdog: if no SSE message arrives for 30s, the connection is dead.
390+
// Safari silently fails EventSource reconnection (readyState stays CONNECTING
391+
// but never actually receives data). Force-close and reconnect.
392+
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
393+
let reconnectWatchdog = null;
394+
395+
function resetWatchdog() {
396+
clearTimeout(reconnectWatchdog);
397+
reconnectWatchdog = setTimeout(() => {
398+
console.warn('[Sentry Live] No events for 5s — forcing SSE reconnect');
399+
Sentry.addBreadcrumb({ category: 'sse', message: 'watchdog triggered reconnect', level: 'warning' });
400+
if (source) {
401+
source.onmessage = null;
402+
source.onerror = null;
403+
source.close();
404+
source = null;
405+
}
406+
connectStream();
407+
}, 5000);
408+
}
409+
389410
function onStreamMessage(e) {
411+
// Track message arrival before the focus guard so the watchdog knows the
412+
// connection is alive even while the tab is hidden.
413+
resetWatchdog();
414+
390415
// Skip all processing while backgrounded — browsers may queue a burst of
391416
// events when a throttled tab is foregrounded, which would freeze the UI.
392417
if (!windowInFocus) return;
@@ -400,16 +425,20 @@ function onStreamMessage(e) {
400425
return;
401426
}
402427

428+
// Server sends periodic heartbeat pings ({}) to keep the connection alive
429+
// and prevent the watchdog from firing during quiet traffic periods.
430+
if (!Array.isArray(parsed)) return;
431+
403432
const [lat, lng, ts, platform] = parsed;
404-
// Drop events whose timestamp is more than 5s away from now (either direction).
405-
// Guards against the browser replaying a burst of buffered SSE messages when a
406-
// throttled tab regains focus. Using Math.abs handles server/client clock skew
407-
// in both directions — without it, a server clock lagging >5s silently empties
408-
// the globe.
409-
if (Math.abs(Date.now() - ts) > 5000) {
433+
// Drop events that are too old — guards against the browser replaying a burst
434+
// of buffered SSE messages when a throttled tab regains focus.
435+
// We only check the past direction: events with a future timestamp are fine
436+
// (the source server's clock may run slightly ahead of the client's clock),
437+
// whereas stale buffered events always arrive with an old timestamp.
438+
if (Date.now() - ts > 10000) {
410439
if (!staleDrop) {
411440
staleDrop = true;
412-
console.warn(`[Sentry Live] Dropping events: clock skew or stale burst detected (ts=${ts}, now=${Date.now()})`);
441+
console.warn(`[Sentry Live] Dropping stale events: buffered burst detected (ts=${ts}, now=${Date.now()}, age=${Date.now() - ts}ms)`);
413442
}
414443
return;
415444
}
@@ -440,48 +469,91 @@ function connectStream() {
440469
source = new EventSource('/stream');
441470
source.onmessage = onStreamMessage;
442471
source.onerror = () => {
443-
// EventSource auto-reconnects on network errors (readyState stays CONNECTING).
444-
// On HTTP errors it enters CLOSED state and won't retry — handle that case manually.
445-
if (source.readyState === EventSource.CLOSED) {
472+
// CLOSED: server rejected the connection (HTTP error) — EventSource won't
473+
// retry automatically, so we do it manually.
474+
// CONNECTING on Safari: Safari sometimes fires onerror but never actually
475+
// reconnects, leaving the source stuck. Force a clean reconnect.
476+
// On other browsers, CONNECTING means the browser is handling reconnection
477+
// with native exponential backoff — don't interfere.
478+
if (source.readyState === EventSource.CLOSED ||
479+
(source.readyState === EventSource.CONNECTING && isSafari)) {
480+
source.onmessage = null;
481+
source.onerror = null;
482+
source.close();
446483
source = null;
447-
Sentry.addBreadcrumb({ category: 'sse', message: 'stream closed (HTTP error), retrying in 3s', level: 'warning' });
448-
console.error('[Sentry Live] Stream closed (HTTP error), retrying in 3s…');
484+
clearTimeout(reconnectWatchdog);
485+
Sentry.addBreadcrumb({ category: 'sse', message: 'stream error, retrying in 3s', level: 'warning' });
486+
console.error('[Sentry Live] Stream error, retrying in 3s…');
449487
setTimeout(connectStream, 3000);
450488
}
451489
};
490+
resetWatchdog();
452491
}
453492

454-
document.addEventListener('visibilitychange', () => {
455-
windowInFocus = !document.hidden;
456-
if (windowInFocus) {
457-
if (ufoHiddenAt !== null) {
458-
// Freeze UFO state timing while RAF is paused in background tabs.
459-
// Use Date.now() for both sides so the delta is in the same wall-clock
460-
// domain as ufoNextAppear and ufoStateStart (which are Date.now()-based).
461-
shiftUfoTimers(Date.now() - ufoHiddenAt);
462-
ufoHiddenAt = null;
463-
}
464-
465-
return;
493+
function onPageVisible() {
494+
windowInFocus = true;
495+
if (ufoHiddenAt !== null) {
496+
// Freeze UFO state timing while RAF is paused in background tabs.
497+
// Use Date.now() for both sides so the delta is in the same wall-clock
498+
// domain as ufoNextAppear and ufoStateStart (which are Date.now()-based).
499+
shiftUfoTimers(Date.now() - ufoHiddenAt);
500+
ufoHiddenAt = null;
466501
}
502+
}
503+
504+
function onPageHidden() {
505+
windowInFocus = false;
467506
ufoHiddenAt = Date.now();
468-
// SSE connection stays open — browsers throttle background tabs naturally
469-
// and the windowInFocus guard at the top of onStreamMessage prevents any
470-
// processing or DOM work while hidden.
507+
}
508+
509+
// `visibilitychange` is the standard, but Safari (especially iOS) sometimes
510+
// fails to fire it when returning from a switched app, leaving windowInFocus
511+
// stuck at false. `pageshow`/`pagehide` and `focus`/`blur` are more reliable
512+
// on Safari and serve as fallbacks.
513+
document.addEventListener('visibilitychange', () => {
514+
if (document.hidden) onPageHidden(); else onPageVisible();
471515
});
516+
// Guard against pageshow firing on initial load in a background tab.
517+
// `pageshow` fires for all page loads (not just bfcache restorations), so
518+
// blindly calling onPageVisible() here would override the document.hidden
519+
// initialisation and mark a background tab as focused.
520+
window.addEventListener('pageshow', () => { if (!document.hidden) onPageVisible(); });
521+
window.addEventListener('pagehide', onPageHidden);
522+
// `focus`/`blur` intentionally not used: `blur` fires when clicking browser
523+
// chrome (address bar, DevTools) and would incorrectly suppress events.
472524

473-
// ── Resize ────────────────────────────────────────────────────────────────────
525+
// ── Resize / breakpoint ───────────────────────────────────────────────────────
474526

475527
window.addEventListener('resize', () => {
476528
camera.aspect = window.innerWidth / window.innerHeight;
477529
camera.updateProjectionMatrix();
478530
renderer.setSize(window.innerWidth, window.innerHeight);
479531
});
480532

481-
mobileQuery.addEventListener('change', e => {
482-
camera.position.set(0, e.matches ? -0.65 : 0, e.matches ? 8.0 : 5.6);
533+
// Adjust camera distance and y-offset at the mobile/desktop breakpoint while
534+
// preserving the current orbital angle so autoRotate doesn't snap the globe.
535+
const CAMERA_DESKTOP = { dist: 2.8, y: 0.0 };
536+
const CAMERA_MOBILE = { dist: 3.5, y: -0.15 };
537+
538+
function applyCameraBreakpoint(cfg) {
539+
// Decompose current position into azimuthal angle around Y axis.
540+
const angle = Math.atan2(camera.position.x, camera.position.z);
541+
// Horizontal component of the new spherical position.
542+
const hDist = Math.sqrt(Math.max(0, cfg.dist * cfg.dist - cfg.y * cfg.y));
543+
camera.position.set(
544+
Math.sin(angle) * hDist,
545+
cfg.y,
546+
Math.cos(angle) * hDist,
547+
);
483548
controls.update();
549+
}
550+
551+
const mobileQuery = window.matchMedia('(max-width: 768px)');
552+
mobileQuery.addEventListener('change', e => {
553+
applyCameraBreakpoint(e.matches ? CAMERA_MOBILE : CAMERA_DESKTOP);
484554
});
555+
// Apply on load.
556+
applyCameraBreakpoint(mobileQuery.matches ? CAMERA_MOBILE : CAMERA_DESKTOP);
485557

486558
// ── Animation loop ────────────────────────────────────────────────────────────
487559

0 commit comments

Comments
 (0)