Dev.to WebDev πŸ›  Dev πŸ‘ 1 πŸ“– 6 min read

I measure session length with a WebSocket that carries no data

I run a small analytics service for Figma plugins. One of the numbers people want from it is session length: how long the plugin panel stayed open. The answer I ended up with is a WebSocket that sends nothing useful. It

I run a small analytics service for Figma plugins. One of the numbers people want from it is session length: how long the plugin panel stayed open.

The answer I ended up with is a WebSocket that sends nothing useful. It opens when the panel opens. It closes when the panel closes. The server subtracts one timestamp from the other.

This post is the client, the server, and the places where that simple idea needed patching.

The two usual ways

The first is an unload event. When the page goes away, send one last request with the duration. The whole measurement then depends on that last request leaving a page that is being destroyed. My code runs in an iframe inside Figma, and Figma removes that iframe whenever the user closes the plugin. I did not want my numbers to depend on what happens in that moment.

The second is a heartbeat. The client POSTs "still here" every few seconds, and the server stores the latest time. This works. It also means the server never learns that a session ended. It only stops hearing about it, so you need a job that scans for sessions that went quiet and closes them.

A WebSocket gives the server the missing piece. When the connection ends, the server's read call returns an error. The end of the session becomes an event the server receives, and no job has to go looking for it.

The client

Here is the core of it, trimmed:

const sessionId =
  `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;

function connect() {
  const query = new URLSearchParams({ ...params, session_id: sessionId });
  const socket = new WebSocket(`${url}?${query}`);

  socket.onopen = () => {
    attempts = 0;
    pingTimer = setInterval(() => {
      if (socket.readyState === WebSocket.OPEN) socket.send('ping');
    }, 5000);
  };

  socket.onclose = () => {
    clearInterval(pingTimer);
    scheduleReconnect();
  };
}

Everything the server needs travels in the query string: the API key, the user id, the plugin id. The browser WebSocket constructor has no way to set request headers, so the query string is the only place for it.

The only message the client ever sends is the text ping, every 5 seconds.

The server

The server is Go. Each socket gets a goroutine, and the whole measurement is a defer:

func (s *Server) session(c *websocket.Conn, info KeyInfo, r *http.Request) {
    start := s.now()
    q := r.URL.Query()

    defer func() {
        c.Close(websocket.StatusNormalClosure, "")
        dur := float32(s.now().Sub(start).Seconds())
        e := EventFromQuery(q, start)
        e.SessionDuration = &dur
        s.writer.Enqueue(e)
    }()

    deadline := start.Add(s.opts.WSMax) // 24h
    for {
        idle := s.now().Add(s.opts.WSIdle) // 60s
        if deadline.Before(idle) {
            idle = deadline
        }
        ctx, cancel := context.WithDeadline(context.Background(), idle)
        typ, msg, err := c.Read(ctx)
        cancel()
        if err != nil {
            return
        }
        if typ == websocket.MessageText && string(msg) == "ping" {
            // answer "pong", return if the write fails
        }
    }
}

The loop has one exit, and that exit is "the read failed". A clean close fails the read. A dropped network fails the read after the idle deadline. A session that hits 24 hours fails the read on the hard deadline. All three land in the same defer, which writes one row with the start time and the duration.

Nothing is written when the socket opens. A session that is still running does not exist in the database yet.

Why ping at all

TCP does not tell you promptly that the other side vanished. If a laptop lid closes, no close frame is sent, and the server can go on believing the connection is alive for a long time.

So the client pings every 5 seconds, and the server gives up after 60 seconds of silence. Without this, one sleeping laptop would produce a session that lasts all night.

The cost is accuracy at the tail. A session that dies silently is counted until the idle deadline fires, so it comes out up to a minute longer than it was. A session that ends with a normal close is exact. I decided a minute of error on the rare case was acceptable. A shorter idle window would cut it, at the price of ending real sessions on a slow network.

Reconnecting

Connections drop for boring reasons: Wi-Fi changes, a proxy times out, I deploy a new server build. The client retries with exponential backoff.

const delay = Math.min(1000 * 2 ** attempts, 30_000);
attempts += 1;
setTimeout(connect, delay);

That is 1s, 2s, 4s, and so on up to 30s. It never stops trying while the panel is open. A plugin can sit open through hours of laptop sleep, and a retry limit would mean the rest of that session is never recorded.

Waiting out a 30 second backoff after the network returns is wasteful, so the client also listens for the browser's online event and reconnects at once:

window.addEventListener('online', () => {
  if (ws) return;
  clearTimeout(reconnectTimer);
  attempts = 0;
  connect();
});

The price of reconnecting

Every reconnect is a new socket, and the server writes one row per socket. One person with a flaky connection becomes five short sessions.

I have only half fixed this. The session_id at the top of the client code is generated once per page load and sent with every connection, so the rows that belong together can be found. The query that merges them is not written yet. Today the dashboard counts them separately.

Deploys

When I restart the server, every open socket would die without its defer finishing if the process exited first. So shutdown closes each connection on purpose and waits for the goroutines:

for _, c := range conns {
    c.Close(websocket.StatusGoingAway, "Server shutting down")
}
// wait up to 5 seconds for the session goroutines to write their rows

The sessions get recorded up to the moment of the deploy. The clients see a close, back off for a second and connect to the new process.

One socket per page

The SDK has a React hook. Call the hook in five components and a naive version opens five sockets, which the server counts as five sessions for one person.

The socket is shared and reference-counted. The first holder opens it. Each later holder gets a handle to the same connection. The connection closes when the last handle is released.

destroy() {
  current.holders -= 1;
  if (current.holders > 0) return;
  shared = null;
  current.socket.destroy();
}

A side effect I did not plan

An open socket is decent evidence that a real UI is open somewhere. The ingest server uses that. Events for a user id with no session seen in the last 30 minutes get a lower daily cap than events for a user with a live one. Someone replaying requests against the HTTP endpoint with a copied API key hits that cap quickly.

What this costs

One open connection per open panel, and one goroutine per connection on the server. For a tool people keep open while they work, the number of connections equals the number of people using it right now, which is also a number I want to see.

It needs a UI. A plugin that runs a command and exits has no page to hold a socket, so it gets no session length at all.

And it measures "the panel was open". Whether the person was looking at it is a different question that I do not try to answer.

Code

The client is in the open-source SDK: github.com/kolebayev/tode-sdk, file packages/core/src/sessionSocket.ts. The service is tode.

The first post in this series covers the rest of the SDK and why Google Analytics cannot run in a Figma plugin in the first place.

If you measure session length some other way, especially in an embedded context like an extension or a widget, I would like to know how you handle the session that never says goodbye.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.