Analyzing Usage Statistics for a Content Item

Problem

You want to analyze usage statistics for a specific content item.

You might want to understand overall usage trends for a content item, or understanding which users access it most frequently.

Solution

Analyze usage data for the content item. Here, we present two approaches:

  • A data frame with historical content usage for an item, including by-user identification and session duration for Shiny for Python and Shiny for R content.
  • A summary table of content usage by user, including total usage and daily usage.

We also provide an example Quarto dashboard with visualizations of these approaches.

This recipe requires the following inputs:

  1. The content_guid for the content item of interest.
  2. The desired timeframe for the report, defined by:
    • the as_of_date.
    • the number of days_back from the as_of_date to include in the report.
  3. The number of top users to include in the report, defined by top_n. If top_n is set to np.nan (Python), or , all users will be included in the report.
Note

If your Connect version is prior to 2023.10, content usage for Plumber, Voila, Streamlit, Dash, and Bokeh were over-counted in instrumentation metrics. Metrics collected after upgrading to version 2023.10 or greater are accurate.

Retrieving data on historical content views

The code samples below yields content usage data as a data frame.

from posit import connect
from datetime import date, timedelta
import polars as pl
import numpy as np


#### User-defined inputs ####
# 1. specify the guid for the content
content_guid = "INSERT_CONTENT_GUID"

# 2. Specify timeframe for the report. Default is 30 days prior to today
as_of_date = date.today()
days_back = 30

# 3. Specify if the summary should show only the Top N users for the content (e.g., `top_n = 10`), or all users (as indicated by `np.nan`). Default is `np.nan` so that all users are shown.
top_n = np.nan
############################

report_from = as_of_date - timedelta(days=days_back)

client = connect.Client()

# Get content usage data
df = client.metrics.usage.find(
    content_guid=content_guid,
    start=report_from.strftime("%Y-%m-%dT%H:%M:%SZ"),
    end=as_of_date.strftime("%Y-%m-%dT%H:%M:%SZ"))

usage = (pl.DataFrame(df, infer_schema_length=None)
        .with_columns(pl.col("started").str.to_datetime("%Y-%m-%dT%H:%M:%SZ"))
        .with_columns(pl.col("started").cast(pl.Date).alias("day")))
>>> print(usage)
shape: (10, 10)
    content_guid           user_guid    variant_key rendering_id    bundle_id               started            ended    data_version    path           day
             str                 str           null         null         null          datetime[μs]              str             i64    null          date
"0477855b-caeb-…    "3dc75833-f6c2-…           null         null         null   2024-04-11 00:36:00 "2024-04-11T00:…               1    null    2024-04-11
"0477855b-caeb-…    "26a7e881-a6c6-…           null         null         null   2024-04-22 14:19:17 "2024-04-22T14:…               1    null    2024-04-22
"0477855b-caeb-…    "26a7e881-a6c6-…           null         null         null   2024-04-22 14:19:35 "2024-04-22T14:…               1    null    2024-04-22
"0477855b-caeb-…    "26a7e881-a6c6-…           null         null         null   2024-04-22 14:20:33 "2024-04-22T15:…               1    null    2024-04-22
"0477855b-caeb-…    "d03a6b7a-c818-…           null         null         null   2024-04-22 21:04:24 "2024-04-22T22:…               1    null    2024-04-22
"0477855b-caeb-…    "d03a6b7a-c818-…           null         null         null   2024-04-23 17:33:48 "2024-04-23T18:…               1    null    2024-04-23
"0477855b-caeb-…    "1ec56831-7a34-…           null         null         null   2024-04-23 19:29:28 "2024-04-23T19:…               1    null    2024-04-23
"0477855b-caeb-…    "fc80bd89-4488-…           null         null         null   2024-04-24 20:19:10 "2024-04-24T20:…               1    null    2024-04-24
"0477855b-caeb-…    "1ec56831-7a34-…           null         null         null   2024-04-26 15:10:18 "2024-04-26T15:…               1    null    2024-04-26
"0477855b-caeb-…    "1ec56831-7a34-…           null         null         null   2024-04-26 15:18:59 "2024-04-26T15:…               1    null    2024-04-26

The output above can be plotted for a historical perspective on content usage. An example is included in the Quarto dashboard code at the end of this recipe.

library(connectapi)
library(dplyr)
library(lubridate)

#### User-defined inputs ####
# 1. specify the guid for the content
content_guid <- "INSERT_CONTENT_GUID"

# 2. Specify timeframe for the report. Default is 30 days prior to today
as_of_date <- today()
days_back <- 30

# 3. Specify if the summary should show only the Top N users for the content (e.g., `top_n = 10`). Default is `Inf` so that all users are shown.
top_n <- Inf
############################

report_from <- as_of_date - ddays(days_back)

client <- connect()

# Determine if the content type is Shiny or not. This dictates which telemetry endpoint to use

# Retrive content details
content_info <- get_content(client, guid = content_guid)

# Determine type from the `app_mode`
type <- content_info$app_mode

# Query appropriate telemetry endpoint for usage data
if (type == "shiny" || type == "python-shiny") {
  usage <- get_usage_shiny(client,
    content_guid = content_guid,
    from = report_from,
    to = as_of_date,
    limit = Inf
  ) |>
    mutate(day = floor_date(started, "day"))
} else {
  usage <- get_usage_static(client,
    content_guid = content_guid,
    from = report_from,
    to = as_of_date,
    limit = Inf
  ) |>
    mutate(day = floor_date(time, "day"))
}
> usage
# A tibble: 14 × 6
   content_guid      user_guid         started             ended               data_version day
   <chr>             <chr>             <dttm>              <dttm>                     <int> <dttm>
 1 0477855b-caeb-... 1ec56831-7a34-... 2024-03-19 21:09:03 2024-03-19 21:10:05            1 2024-03-19 00:00:00
 2 0477855b-caeb-... 1ec56831-7a34-... 2024-03-19 21:39:07 2024-03-19 22:39:48            1 2024-03-19 00:00:00
 3 0477855b-caeb-... d59e7a7b-9684-... 2024-03-20 00:36:29 2024-03-20 00:37:10            1 2024-03-20 00:00:00
 4 0477855b-caeb-... 1ec56831-7a34-... 2024-03-20 13:50:55 2024-03-20 14:04:04            1 2024-03-20 00:00:00
 5 0477855b-caeb-... 1ec56831-7a34-... 2024-03-20 16:43:56 2024-03-20 16:44:12            1 2024-03-20 00:00:00
 6 0477855b-caeb-... d59e7a7b-9684-... 2024-03-21 10:52:14 2024-03-21 11:52:37            1 2024-03-21 00:00:00
 7 0477855b-caeb-... fc80bd89-4488-... 2024-03-25 19:16:45 2024-03-25 19:28:19            1 2024-03-25 00:00:00
 8 0477855b-caeb-... 989001e2-66df-... 2024-03-26 19:48:37 2024-03-26 21:43:10            1 2024-03-26 00:00:00
 9 0477855b-caeb-... 5c7cb633-5959-... 2024-03-28 17:13:03 2024-03-28 17:19:35            1 2024-03-28 00:00:00
10 0477855b-caeb-... fc80bd89-4488-... 2024-03-28 18:26:20 2024-03-28 18:34:40            1 2024-03-28 00:00:00
11 0477855b-caeb-... fc80bd89-4488-... 2024-03-28 20:53:50 2024-03-28 20:54:36            1 2024-03-28 00:00:00
12 0477855b-caeb-... cbee884e-f940-... 2024-03-29 16:13:44 2024-03-29 16:14:15            1 2024-03-29 00:00:00
13 0477855b-caeb-... fc80bd89-4488-... 2024-03-29 18:16:10 2024-03-29 20:43:52            1 2024-03-29 00:00:00
14 0477855b-caeb-... 3dc75833-f6c2-... 2024-04-11 00:36:00 2024-04-11 00:36:59            1 2024-04-11 00:00:00

Usage data for non-Shiny content also includes columns identifying variant_key, rendering_id, bundle_id, and path. These columns are not used for this recipe.

The output above can be plotted for a historical perspective on content usage. An example is included in the Quarto dashboard code at the end of this recipe.

Identifying usage by user

The code samples below yield by-user content usage data as a data frame.

# Get user details to cross reference `user_guid` with usernames
all_users = pl.DataFrame(client.users.find())

# Determine number of rows to truncate to, based on `top_n`
if np.isnan(top_n):
  # have to define a very high number to feed into `head` below
  truncation_rows = 99999
else:
  truncation_rows = top_n


# Make a table of a user's total usage
user_tot_usage = (
  usage.group_by("user_guid")
  .agg(pl.len().alias("tot_usage"))
  # change any `nan` user_guid to "anonymous"
  .with_columns(pl.coalesce(pl.col("user_guid"), pl.lit("anonymous")).alias("user_guid"))
)

# Make a table of a user's daily usage and combine with total usage
by_user_usage = (
    usage.group_by(["user_guid","day"])
    .agg(pl.len()
    .alias("daily_usage"))
    .join(all_users, left_on="user_guid", right_on="guid", how="left")
    .with_columns(pl.coalesce(pl.col("username"), pl.lit("anonymous")).alias("username"))
    .with_columns(pl.coalesce(pl.col("user_guid"), pl.lit("anonymous")).alias("user_guid"))
    # join with total usage table
    .join(user_tot_usage, on="user_guid", how="left")
    .select(["username", "day","tot_usage", "daily_usage"])
    .sort("tot_usage",descending=True)
    .head(truncation_rows)
)
>>> print(by_user_usage)
shape: (11, 4)
 username          day  tot_usage   daily_usage
      str         date         u32           u32
"lisa"      2024-03-20           4             2
"katie"     2024-03-28           4             2
"katie"     2024-03-29           4             1
"lisa"      2024-03-19           4             2
"katie"     2024-03-25           4             1
         …           …           …             …
"andrie"    2024-03-21           2             1
"ryan"      2024-04-11           1             1
"rika"      2024-03-28           1             1
"jonathan"  2024-03-29           1             1
"connor"    2024-03-26           1             1

The output above can be plotted for a historical perspective on content views. An example is included in the Quarto dashboard code at the end of this recipe.

# Get user details to cross reference `user_guid` with usernames
all_users <- get_users(client, limit = Inf)

# Join user data with usage data
by_user_usage <- usage |>
  select(user_guid, day) |>
  group_by(user_guid) |>
  mutate(tot_usage = n()) |>
  mutate(first_visit = min(day)) |>
  mutate(last_visit = max(day)) |>
  ungroup() |>
  group_by(user_guid, day) |>
  mutate(daily_usage = n()) |>
  ungroup() |>
  left_join(all_users, by = c(user_guid = "guid")) |>
  mutate(username = coalesce(username, "anonymous")) |>
  select(username, day, tot_usage, first_visit, last_visit, daily_usage) |>
  distinct() |>
  arrange(desc(tot_usage)) |>
  pivot_wider(names_from = day, values_from = daily_usage) |>
  head(top_n)

by_user_usage
# A tibble: 11 × 4
   username  day                 total_usage daily_usage
   <chr>    <dttm>                     <int>        <int>
 1 lisa     2024-03-19 00:00:00            4            2
 2 lisa     2024-03-20 00:00:00            4            2
 3 katie    2024-03-25 00:00:00            4            1
 4 katie    2024-03-28 00:00:00            4            2
 5 katie    2024-03-29 00:00:00            4            1
 6 andrie   2024-03-20 00:00:00            2            1
 7 andrie   2024-03-21 00:00:00            2            1
 8 connor   2024-03-26 00:00:00            1            1
 9 rika     2024-03-28 00:00:00            1            1
10 jonathan 2024-03-29 00:00:00            1            1
11 ryan     2024-04-11 00:00:00            1            1

The output above can be plotted for a historical perspective on content views. An example is included in the Quarto dashboard code at the end of this recipe.

Deploying a sample Quarto dashboard with these metrics

The Quarto document below builds visualizations around the data frames retrieved above. To use, copy the source code into a new .qmd file. This can be deployed in your own environment as is, or customized to your needs.

# requirements.txt
great-tables==0.4.0
jupyter==1.0.0
numpy==1.26.4
pandas==2.2.1
plotnine==0.13.3
polars==0.20.18
posit-sdk==0.2.0
pyarrow==15.0.2
PyYAML==6.0.1
setuptools==69.2.0
---
title: Historical Content Usage
format:
  dashboard:
     theme: default
     orientation: rows
---

```{python}
#| label: setup
from posit import connect
from datetime import date, timedelta
import os
import sys
import polars as pl
import numpy as np
import plotnine as p9
from great_tables import GT, nanoplot_options, style, loc, system_fonts, md
```


```{python}
#| label: user-defined inputs

# 1. specify the guid for the content
content_guid = "INSERT_CONTENT_GUID"

# 2. Specify timeframe for the report. Default is 30 days prior to today
as_of_date = date.today()
days_back = 30

# 3. Specify if the summary should show only the Top N users for the content (e.g., `top_n = 10`), or all users (as indicated by `np.nan`). Default is `np.nan` so that all users are shown.
top_n = 20
top_n = np.nan

# Note: The CONNECT_SERVER and CONNECT_API_KEY environment variables are required for this report to pull usage data. Use environment variables for added security; do not hard-code your API key or other secrets.
```


```{python}
#| label: report timeframe

report_from = as_of_date - timedelta(days=days_back)
```



```{python}
#| label: Establish connection to Connect server

client = connect.Client()
```

```{python}
# | label: Get content info and usage

# Get content usage data
df = client.metrics.usage.find(
    content_guid=content_guid,
    start=report_from.strftime("%Y-%m-%dT%H:%M:%SZ"),
    end=as_of_date.strftime("%Y-%m-%dT%H:%M:%SZ"))

usage = (pl.DataFrame(df, infer_schema_length=None)
        .with_columns(pl.col("started").str.to_datetime("%Y-%m-%dT%H:%M:%SZ"))
        .with_columns(pl.col("started").cast(pl.Date).alias("day")))


# Get content details
content_info = client.content.get(content_guid)


# Get all user details to cross reference user_guids with usernames
all_users = pl.DataFrame(client.users.find())

# Count total hits and unique visitors
total_hits_in_period = usage.height
unique_visitors = (
    usage.filter(pl.col("user_guid").is_not_null()).select("user_guid").n_unique()
)
```

### Row

Usage of **`{python} content_info.title`**, for the period of **`{python} report_from.strftime("%Y-%m-%d")`** to **`{python} as_of_date.strftime("%Y-%m-%d")`**


### Row

```{python}
#| content: valuebox
#| title: "Total content hits in period"
#| icon: binoculars
#| color: "success"
dict(
  value = total_hits_in_period
)
```

```{python}
#| content: valuebox
#| title: "Unique visitors in period"
#| icon: people
#| color: "success"
dict(
  value = unique_visitors
)
```


### Row

```{python}
# plot content usage over time

usage_plot_data = (usage.group_by("day")
  .agg(pl.len().alias("usage"))
  .sort("usage", descending=True)
  .select("day", "usage")
)

# generate plot
p9.ggplot(usage_plot_data, p9.aes(x="day", y="usage")) + \
    p9.geom_point(color="#AE929F", size=1) + \
    p9.geom_smooth(se=False, color="#546A7B") + \
    p9.theme_minimal() + \
    p9.labs(y=None, x=None, title="Content usage for " + content_info["title"])
```


```{python}
#| html-table-processing: none
# Create table of content usage by user

# Define table title and whether to truncate rows based on `top_n`
if np.isnan(top_n):
  title = "Content usage by user"
  # have to define a very high number to feed into `head` below
  truncation_rows = 99999
else:
  title = "Top {} content visitors".format(top_n)
  truncation_rows = top_n

# Make a table of a user's total usage
user_tot_usage = (
  usage.group_by("user_guid")
  .agg(pl.len().alias("tot_usage"))
  # change any `nan` user_guid to "anonymous"
  .with_columns(pl.coalesce(pl.col("user_guid"), pl.lit("anonymous")).alias("user_guid"))
)

# In the specified time period, when was each user's first and last visit?
user_first_last_visit = (
  usage.group_by("user_guid")
  .agg(pl.min("day").alias("first_visit"), pl.max("day").alias("last_visit"))
  .with_columns(pl.coalesce(pl.col("user_guid"), pl.lit("anonymous")).alias("user_guid"))
)

# Make a table of a user's daily usage
user_daily_usage = (
  usage.group_by(["user_guid","day"])
  .agg(pl.len().alias("daily_usage"))
  .with_columns(pl.coalesce(pl.col("user_guid"), pl.lit("anonymous")).alias("user_guid"))
  .join(user_first_last_visit, on="user_guid", how="left")
  .join(all_users, left_on="user_guid", right_on="guid", how="left")
  .with_columns(pl.coalesce(pl.col("username"), pl.lit("anonymous")).alias("username"))
  .select(["username", "user_guid", "day", "daily_usage", "first_visit", "last_visit"])
  .sort("day")
  .pivot(index=["username","user_guid", "first_visit", "last_visit"], values="daily_usage", columns="day")
  .join(user_tot_usage, on="user_guid", how="left")
  .sort("tot_usage", descending=True)
  .drop("user_guid")
  .head(truncation_rows)
  .select("username","tot_usage","first_visit","last_visit",pl.col("*").exclude("username","tot_usage","first_visit","last_visit"))
)

# combine all access data columns into one column for nanoplot
nanoplot_columns = user_daily_usage.drop("tot_usage","username","first_visit","last_visit").columns

# format the usage data for `great_tables` and nanoplot
user_daily_usage_concatenated = (user_daily_usage
  .fill_null(0)
  .with_columns(pl.concat_str([pl.col(nanoplot_columns)], separator=" ").alias("combined"))
  .select("username", "tot_usage", "first_visit","last_visit", "combined")
)

# Create table
(
  GT(user_daily_usage_concatenated, rowname_col="username")
  .tab_header(title=title)
  .tab_stubhead(label="username")
  .cols_label(tot_usage = "total usage", first_visit = "first visit", last_visit = "last visit")
  .tab_source_note("First and last usage reported within the specified timeframe")
  .fmt_nanoplot(
    columns="combined",
    autoscale=False,
    options=nanoplot_options(
        show_data_points = False,
        data_area_fill_color = "#CFD9C7",
        data_line_stroke_color = "#546A7B"
    )
  )
)
```


##

```{python}
#| html-table-processing: none
# make a table of content details

# Clean content info
wide_info = (
  pl.DataFrame(content_info)
  .select("created_time", "last_deployed_time",
          "content_url", "dashboard_url", "owner_guid")
  # join with user info table to identify owner username
  .join(all_users.select("guid", "username", "email"),left_on="owner_guid", right_on="guid", how="left")
  .rename({"username": "content_owner",
          "email": "owner_email"})
  .drop("owner_guid")
  # reorder columns
  .select("content_owner","owner_email",pl.col("*").exclude("content_owner","owner_email"))
  # format the urls TODO: this will be unnecessary when great_tables adds fmt_url. See https://github.com/posit-dev/great-tables/issues/80
  .with_columns(pl.col(["content_url","dashboard_url"]).map_elements(lambda x: f"<{x}>", return_dtype=pl.String))
  .with_columns(pl.col(["owner_email"]).map_elements(lambda x: f"[{x}](mailto:{x})", return_dtype=pl.String))
  # remove timestamp from date fields
  .with_columns(pl.col(["created_time","last_deployed_time"]).str.replace(r"T.*", "")
  )
)

# Pivot content table for nicer viewing
long_info = (
  wide_info
  .melt(value_vars=wide_info.columns, variable_name="item", value_name="value")
)

# Create table
(
  GT(long_info, rowname_col="item")
  .tab_options(column_labels_hidden=True)
  .tab_style(style=style.text(font="Courier"),locations=loc.body(columns="value"))
  .fmt_markdown(columns="value", rows=["content_url", "dashboard_url", "owner_email"])
  .tab_style(style=style.text(decorate="underline"),locations=loc.body(rows=["content_url","dashboard_url","owner_email"], columns="value"))
)

```
---
title: Historical Content Usage
format:
  dashboard:
     theme: default
     orientation: rows
---

```{r}
#| label: setup
#| include: false

library(connectapi)
library(tidyverse)
library(gt)
library(plotly)
```


```{r}
#| label: user-defined inputs
#| include: false

# 1. specify the guid for the content
content_guid <- "INSERT_CONTENT_GUID"

# 2. Specify timeframe for the report. Default is 30 days prior to today
as_of_date <- lubridate::today()
days_back <- 30

# 3. Specify if the summary should show only the Top N users for the content (e.g., `top_n = 10`). Default is `Inf` so that all users are shown.
top_n <- Inf

# Note: The CONNECT_SERVER and CONNECT_API_KEY environment variables are required for this report to pull usage data. Use environment variables for added security; do not hard-code your API key or other secrets.
```


```{r}
#| label: report timeframe
#| include: false

report_from <- as_of_date - lubridate::ddays(days_back)
```

```{r}
#| label: Establish connection to Connect server
#| include: false

client <- connect()
```

```{r}
#| label: Get content info and usage
#| include: false

# Determine if the content type is Shiny or not. This will dictate which telemetry endpoint to use
# Retrieve content details
content_info <- connectapi::get_content(client, guid = content_guid)

# Determine type from the `app_mode`
type <- content_info$app_mode

### Query appropriate telemetry endpoint for usage data
if (type == "shiny" || type == "python-shiny"){
  usage <- connectapi::get_usage_shiny(client,
                                       content_guid = content_guid,
                                       from = report_from,
                                       to = as_of_date,
                                       limit = Inf) |>
            dplyr::mutate(day = lubridate::floor_date(started, "day"))
} else {
  usage <- connectapi::get_usage_static(client,
                                        content_guid = content_guid,
                                        from = report_from,
                                        to = as_of_date,
                                        limit = Inf) |>
            dplyr::mutate(day = lubridate::floor_date(time, "day"))

}

# Get all user details to cross reference user_guids with usernames
all_users <- connectapi::get_users(client, limit = Inf)

# Count total usage and unique visitors
total_hits_in_period <- nrow(usage)
unique_visitors <- usage |> filter(!is.na(user_guid)) |>pull(user_guid) |> unique() |> length()
```

###  Row

Usage of **`{r} content_info$title`**, for the period of **`{r} report_from`** to **`{r} as_of_date`**


###  Row

```{r}
#| content: valuebox
#| title: "Total content hits in period"
#| icon: binoculars
#| color: "success"
list (
  value = total_hits_in_period
)
```

```{r}
#| content: valuebox
#| title: "Unique viewers in period"
#| icon: people
#| color: "success"
list (
  value = unique_visitors
)
```


###  Row

```{r}
#| label: plot content usage over time

usage_plot_data <- usage |>
    group_by(day) |>
    summarise(usage = n()) |>
    arrange(desc(usage))

 # generate plot
 {ggplot(usage_plot_data, aes(day, usage)) +
     geom_point(color = "#AE929F", size = 1) +
     geom_smooth(se = FALSE, color = "#546A7B") +
     theme_minimal() +
     labs(
       y = NULL,
       x = NULL,
       title = glue::glue("Content usage for {content_info$title}"),
     )} |>
      ggplotly(tooltip = c("y")) |>
      config(displayModeBar = FALSE)
```


```{r}
#| label: Create table of content usage by user
#| html-table-processing: none

# Make a table of a user's daily usage

usage |>
  select(user_guid, day) |>
  group_by(user_guid) |>
  mutate(tot_usage = n()) |>
  mutate(first_visit = min(day)) |>
  mutate(last_visit = max(day)) |>
  ungroup() |>
  group_by(user_guid, day) |>
  mutate(daily_usage = n()) |>
  ungroup() |>
  left_join(all_users, by = c(user_guid = "guid")) |>
  mutate(username = coalesce(username, "anonymous")) |>
  select(username, day, tot_usage, first_visit, last_visit, daily_usage) |>
  distinct() |>
  arrange(desc(tot_usage)) |>
  pivot_wider(names_from = day, values_from = daily_usage) |>
  head(top_n) |>
  gt(rowname_col = "username") |>
    tab_header(
  title = if(top_n == Inf){
    paste("Content usage by user")
    }else{
    paste("Top", top_n, "content users")})  |>
  tab_stubhead(label = "username") |>
  cols_label(tot_usage = "total usage", first_visit ="first visit", last_visit="last visit") |>
  tab_footnote(footnote=glue::glue("First and last usage in the period {report_from} to {as_of_date}"), locations=cells_column_labels(columns=c("first_visit", last_visit)) ) |>
  cols_nanoplot(
      columns = -c(tot_usage, first_visit, last_visit),
      new_col_name = "daily usage",
      missing_vals = "zero",
      autoscale = FALSE,
      options = nanoplot_options(
        show_data_points = FALSE,
        data_area_fill_color = "#CFD9C7",
        data_line_stroke_color = "#546A7B"
        )
  )
```

##

```{r}
#| label: make a table of content details
#| html-table-processing: none


# Clean content info and present as a table
content_info |>
  select(created_time, last_deployed_time, content_url, dashboard_url, owner_guid) |>
  # join with user info table to identify owner username
  left_join(select(all_users,guid, username, email), by = c(owner_guid = "guid")) |>
  rename(
    content_owner = username,
    owner_email = email) |>
  select(-owner_guid) |>
  relocate(content_owner, owner_email) |>
  mutate(across(everything(), as.character)) |>
  # format owner email as a hyperlink
  # TODO: resolution of https://github.com/rstudio/gt/issues/1616 will make this unnecessary
  mutate(owner_email, owner_email=glue::glue("[{owner_email}](mailto:{owner_email})")) |>
  # convert to long format for better formatting of summary table
  pivot_longer(everything(), names_to = "item", values_to = "value") |>
  gt(rowname_col = "item") |>
    tab_options(column_labels.hidden = TRUE) |>
    tab_style(
      style = cell_text(font = system_fonts("monospace-code")),
      locations = cells_body(columns = value)
      ) |>
    fmt_url(columns = value, rows = c("dashboard_url", "content_url","owner_email")) |>
    fmt_date(columns = value, rows = c("created_time", "last_deployed_time"), date_style = "iso") |>
  cols_align(
    align = "left",
    columns = value
  )
```