---
title: "Support context for product-led teams | Snowplow Signals"
description: "Snowplow Signals tells your support desk or assistant the page a user was stuck on and the error they hit before writing in, so the first reply is the answer."
source: https://signals.snowplow.io/solutions/b2b-software/support
---

[B2B software](https://signals.snowplow.io/solutions/b2b-software) / Support context 

# A ticket that arrives with the error attached. 

Signals tells your support desk or assistant the page this user was stuck on, the error they hit and the docs they read before they wrote in, so the first reply can name the fix instead of asking for a screenshot.

[Start free](https://signals.snowplow.io/start) [Talk to us](https://signals.snowplow.io/start?intent=talk) 

Shorter time to resolution

## Close tickets sooner by starting the reply from what the user did before they wrote in. 

Time to resolution moves when the first reply is an answer rather than a question. Signals gives your support desk or assistant the errors this user hit in the last hour, the docs page they opened, the import that failed and the setup steps their account has taken this week. Your desk or assistant decides the reply and where the ticket goes.

Four ways product-led teams use it

01 A user who keeps hitting one error The same error three times in an hour, then a ticket. Name the fix, skip the screenshot. 02 A user who read the docs first Read the webhooks docs page, wrote in a minute later. Answer from that page. 03 An admin on an account mid-setup Admin asks why the dashboard is empty. A teammate connected the source ten minutes ago. Say so. 04 A user whose import just failed Import failed, ticket four minutes later. Route to the data team with the import id. 

[Read the docs →](https://docs.snowplow.io/docs/signals/introduction/) 

### Name the error and the fix in the first reply

A user has hit the same error three times in the last hour, each time on the field mapping screen, and now they write in. The ticket reaches the desk as a fresh conversation, so the first reply asks what they clicked and for a screenshot, and the error that was recorded three times waits for the second exchange. The error code and how often it fired can be on the ticket before anyone opens it, and the first reply can name the fix.

What Signals knows

`errors_by_type_1h` 

Errors shown in the last hour, counted by error code

`last_error_1h` 

The error code shown most recently

What your support desk does

Attaches the error code and its count to the ticket as it is created and suggests the help article your team keeps for that code, so the agent's first reply names the fix. The agent decides whether to send it.

Define it · PythonUse it · TypeScript

support.ts · 18 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, EventProperty, StreamAttributeGroup, user_id

error_shown = Event(name="error_shown", vendor="com.example", version="1-0-0")
error_code = EventProperty(vendor="com.example", name="error_shown", major_version=1, path="code")

errors_by_type_1h = Attribute(
    name="errors_by_type_1h",
    type="dict",
    events=[error_shown],
    aggregation="category_count",
    property=error_code,
    period=timedelta(hours=1),
)

last_error_1h = Attribute(
    name="last_error_1h",
    type="string",
    events=[error_shown],
    aggregation="last",
    property=error_code,
    period=timedelta(hours=1),
)

# Keyed on the user, since the ticket carries who wrote in, not which browser they used.
saas_support_errors = StreamAttributeGroup(
    name="saas_support_errors",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[errors_by_type_1h, last_error_1h],
)
```

```
// In the handler that creates the ticket when a user writes in
const user = await signals.getServiceAttributes({
  name: "saas_support_errors",
  attribute_key: "user_id",
  identifier: userId,
});
// { errors_by_type_1h: { mapping_type_mismatch: 3 }, last_error_1h: "mapping_type_mismatch" }

const code = user.last_error_1h;
const count = user.errors_by_type_1h?.[code] ?? 0;

if (count >= 2) {
  desk.annotate({
    ticket,
    note: `Hit ${code} ${count} times in the last hour`,
    suggestedReply: help.articleFor(code),
  });
}
```

### Answer from the docs page they were reading

A user opened the docs page for webhooks, read it, and opened the help widget a minute later. The assistant greets them with a general question, and the user types out the thing the page they just left covers. The page they were reading is the topic. The reply can start from it, with that page as its source, and a person can take over if it does not close the question.

What Signals knows

`docs_pages_1h` 

Docs pages this user opened in the last hour

`last_docs_page_1h` 

The docs page they opened most recently

What your support desk does

Gives the assistant the docs page the user came from, and the others they opened, as the sources for its first reply when a user writes in soon after reading docs. The assistant writes the reply and hands off to a person when it cannot close the question.

Define it · PythonUse it · TypeScript

support.ts · 15 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import (
    Attribute, AtomicProperty, Criteria, Criterion, Event, StreamAttributeGroup, user_id,
)

page_view = Event(name="page_view", vendor="com.snowplowanalytics.snowplow", version="1-0-0")
on_docs = Criteria(all=[
    Criterion.like(AtomicProperty(name="page_url"), "%/docs/%"),
])

docs_pages_1h = Attribute(
    name="docs_pages_1h",
    type="string_list",
    events=[page_view],
    aggregation="unique_list",
    property=AtomicProperty(name="page_url"),
    criteria=on_docs,
    period=timedelta(hours=1),
)

last_docs_page_1h = Attribute(
    name="last_docs_page_1h",
    type="string",
    events=[page_view],
    aggregation="last",
    property=AtomicProperty(name="page_url"),
    criteria=on_docs,
    period=timedelta(hours=1),
)

# The docs site and the app share the login, so docs reads land on the same user id.
saas_support_docs = StreamAttributeGroup(
    name="saas_support_docs",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[docs_pages_1h, last_docs_page_1h],
)
```

```
// In the handler that opens the assistant when a user writes in
const user = await signals.getServiceAttributes({
  name: "saas_support_docs",
  attribute_key: "user_id",
  identifier: userId,
});
// { docs_pages_1h: ["https://app.example/docs/webhooks", "https://app.example/docs/webhooks/retries"], last_docs_page_1h: "https://app.example/docs/webhooks/retries" }

if (user.last_docs_page_1h) {
  assistant.open({
    topic: topicFromUrl(user.last_docs_page_1h),
    sources: user.docs_pages_1h,
    fallback: "agent",
  });
}
```

### Show the desk what the account did this week

An admin writes in asking why the dashboard shows nothing, while a teammate on the same account connected the data source ten minutes ago and has not mapped the fields yet. The desk sees one user's ticket and the account's plan, while the setup state sits with the teammate who did the work, so the reply asks the admin to check with their team. The answer is in what the account did this week. With those steps on the ticket, the reply can say setup is mid-way and name the step that is left.

What Signals knows

`account_steps_7d` 

Setup steps anyone on the account completed in the last seven days

`last_account_step_7d` 

The step completed most recently, by anyone on the account

`setup_users_7d` 

How many people on the account completed a step this week

What your support desk does

Puts the account's completed steps and how many people worked on them beside the ticket, so the reply can say setup is in progress and which step comes next. Which steps make a complete setup comes from your onboarding checklist.

Define it · PythonUse it · TypeScript

support.ts · 17 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import (
    Attribute, AttributeKey, AtomicProperty, EntityProperty, Event, EventProperty, StreamAttributeGroup,
)

step_completed = Event(name="onboarding_step_completed", vendor="com.example", version="1-0-0")
step = EventProperty(vendor="com.example", name="onboarding_step_completed", major_version=1, path="step")
account = AttributeKey(
    name="account",
    property=EntityProperty(vendor="com.example", name="account", major_version=1, path="id"),
)

account_steps_7d = Attribute(
    name="account_steps_7d",
    type="string_list",
    events=[step_completed],
    aggregation="unique_list",
    property=step,
    period=timedelta(days=7),
)

last_account_step_7d = Attribute(
    name="last_account_step_7d",
    type="string",
    events=[step_completed],
    aggregation="last",
    property=step,
    period=timedelta(days=7),
)

setup_users_7d = Attribute(
    name="setup_users_7d",
    type="int32",
    events=[step_completed],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="user_id"),
    period=timedelta(days=7),
)

# Keyed on the account, so a step one teammate completes shows up on another's ticket.
saas_support_account = StreamAttributeGroup(
    name="saas_support_account",
    version=1,
    attribute_key=account,
    owner="you@example.com",
    attributes=[account_steps_7d, last_account_step_7d, setup_users_7d],
)
```

```
// In the handler that creates the ticket, with the account the user belongs to
const account = await signals.getServiceAttributes({
  name: "saas_support_account",
  attribute_key: "account",
  identifier: accountId,
});
// { account_steps_7d: ["invite_team", "connect_source"], last_account_step_7d: "connect_source", setup_users_7d: 2 }

const remaining = setup.steps.filter((s) => !account.account_steps_7d.includes(s));

if (remaining.length > 0) {
  desk.annotate({
    ticket,
    note: `Setup in progress. Last step ${account.last_account_step_7d}, next is ${remaining[0]}.`,
    suggestedReply: help.articleFor(remaining[0]),
  });
}
```

### Route the ticket to the data team with the import id

A user started an import, it failed on a date column it could not parse, and four minutes later their ticket arrives saying the upload did not work. It lands in the general queue, where the first reply asks for the file and the import id, and the team that can read the import log sees it a hop later. The import and the reason it failed were recorded when it happened. The ticket can carry both to the team that owns imports on its first hop.

What Signals knows

`last_failed_import_id_1h` 

The id of the import that failed most recently

`last_import_error_1h` 

Why that import failed

`minutes_since_import_failed` 

Minutes since the last failed import

What your support desk does

Routes the ticket to the data team's queue with the import id and the failure reason attached when an import failed in the half hour before the user wrote in. Your routing rules pick the queue and the person.

Define it · PythonUse it · TypeScript

support.ts · 18 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, EventProperty, StreamAttributeGroup, user_id

import_failed = Event(name="import_failed", vendor="com.example", version="1-0-0")

last_failed_import_id_1h = Attribute(
    name="last_failed_import_id_1h",
    type="string",
    events=[import_failed],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="import_failed", major_version=1, path="import_id"),
    period=timedelta(hours=1),
)

last_import_error_1h = Attribute(
    name="last_import_error_1h",
    type="string",
    events=[import_failed],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="import_failed", major_version=1, path="reason"),
    period=timedelta(hours=1),
)

minutes_since_import_failed = Attribute(
    name="minutes_since_import_failed",
    type="double",
    events=[import_failed],
    aggregation="time_since_last",
    time_unit="min",
)

# import_failed is sent from the server when the job ends, so the user id is the key it shares with the ticket.
saas_support_import = StreamAttributeGroup(
    name="saas_support_import",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[last_failed_import_id_1h, last_import_error_1h, minutes_since_import_failed],
)
```

```
// In the handler that routes a new ticket
const user = await signals.getServiceAttributes({
  name: "saas_support_import",
  attribute_key: "user_id",
  identifier: userId,
});
// { last_failed_import_id_1h: "imp_7f3a", last_import_error_1h: "date_parse", minutes_since_import_failed: 4.2 }

if (user.last_failed_import_id_1h && user.minutes_since_import_failed <= 30) {
  desk.route({
    ticket,
    queue: "data_team",
    context: {
      importId: user.last_failed_import_id_1h,
      reason: user.last_import_error_1h,
    },
  });
}
```

## Try it on your own site. 

Add the SDK, define one thing you want to know about a visitor, and watch it update while you click around. The free tier runs on the real engine: no card, no sales call.

[Start free](https://signals.snowplow.io/start) [Talk to an engineer](https://signals.snowplow.io/start?intent=engineer)
