The chain
Visitor to Session to Customer to Conversation to Ticket. The product thesis, and the five foreign keys that make it true.
Everything else in this documentation is detail. This page is the argument.
Visitor, Session, Customer, Conversation and Ticket are all the same record.
Why this is a schema claim
Every support product will tell you it gives an agent context. Most of them mean that a sidebar fetches some related data when a chat opens, and that the join is done at read time by a feature, against whatever the systems happened to record.
Here the links are foreign keys on rows that already exist. That difference shows up in three places where the read-time approach quietly fails:
- A visitor who was anonymous for weeks. When
identify()finally names them,Visitor.customer_idis set and every prior session is back-filled. Their history did not begin this morning. A read-time join has nothing to join to, because nothing linked those sessions to anyone. - A ticket read a month later.
origin_path,visitor_idandcustomer_idare columns on the ticket, written when it was escalated. The answer to "which page produced this" survives even after the session has been pruned. - A page-level support report.
most_support_requestsin analytics is only computable because the originating path rode along from the conversation to the ticket. It is not derivable afterwards from anything else.
The five links
| From | To | Column | Written when |
|---|---|---|---|
| Session | Visitor | visitor_sessions.visitor_id | The session starts |
| Visitor | Customer | visitors.customer_id | identify() succeeds |
| Conversation | Visitor, Customer, Session | conversations.visitor_id, .customer_id, .session_id | The thread is created |
| Ticket | Conversation | tickets.conversation_id | Escalation |
| Ticket | Visitor, Customer | tickets.visitor_id, .customer_id | Escalation, carried through |
Note that the ticket keeps its own pointers to the visitor and the customer rather than reaching them through the conversation. That is not redundancy for its own sake: a ticket can be filed without a conversation, and a conversation can be deleted under a retention policy while the ticket must survive.
Walking it forward
A real sequence, in the order the rows appear.
A browser arrives
POST /v1/track/session creates a Visitor (or recognises a returning one by
its opaque token) and a VisitorSession.
The session captures referrer, referrer_host, utm_source, utm_medium,
utm_campaign, landing_path, device_type, browser, os, country_code
and language. All of it at the moment of arrival, because most of it is not
recoverable later.
They read things
POST /v1/track/pageview writes a PageView and closes the previous one's
duration, so duration_seconds and page_view_count are always current rather
than computed at the end.
Anything worth a line on a human-readable timeline also writes a
VisitorEvent: a knowledge base search, an article read, the chat opening.
They sign in
The portal calls TaifaSupport.identify({ id: "183", ... }).
POST /v1/track/identify creates or finds the Customer for that
external_id, sets Visitor.customer_id, and back-fills every prior
session. The person's history did not start at this moment; the system only
just learned whose it was.
They ask something
A Conversation is created carrying visitor_id, customer_id, session_id
and site_id.
The agent's screen already has everything on the live row: the pages, the time, the country, the open tickets. Nothing had to be asked for.
It becomes an obligation
POST /v1/conversations/{id}/escalate creates a Ticket carrying the visitor,
the customer and origin_path.
A TicketEvent is written for every change from then on, so the record of who
changed what and when is not a reconstruction from application logs.
What this looks like on screen
Acme LtdPricing → Enterprise
2 open tickets
Not a mock-up. Every value is a column, and the row is one query.
Why the competition does not have this
Not because it is hard, but because their visitor list was never the point. The common set of columns is name, last page, country, browser, entry time, tags and new-or-returning, with two filters and no sorting.
Absent entirely, and present here:
| Missing elsewhere | Column here |
|---|---|
| Referrer | VisitorSession.referrer, .referrer_host |
| Traffic source and campaign | .utm_source, .utm_medium, .utm_campaign |
| Time on site | .duration_seconds, .started_at |
| Page-view count | .page_view_count, Visitor.page_view_count |
| Full page path with timestamps | The PageView table |
| Open tickets on the row | tickets.visitor_id |
Every one of those is already stored. That is the clearest single reason an
Institution picks this, which is why the /visitors screen shows all of it and
why any feature that does not serve that row is a lower priority than one that
does.
What follows from the thesis
Two rules that come directly out of it:
page_viewsis designed to be pruned first. It is dense, high-volume, and the least valuable row for row.visitor_eventsis the sparse, human-readable timeline an agent actually reads, and it is kept longer. See pruning.- The visitor stream is a privilege.
guest, the role for an external collaborator on one ticket, deliberately cannot see any of it. Whole-journey visibility is exactly the thing you do not hand to a vendor.