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:
- The
content_guid
for the content item of interest. - The desired timeframe for the report, defined by:
- the
as_of_date
. - the number of
days_back
from theas_of_date
to include in the report.
- the
- The number of top users to include in the report, defined by
top_n
. Iftop_n
is set tonp.nan
(Python), or , all users will be included in the report.
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
= "INSERT_CONTENT_GUID"
content_guid
# 2. Specify timeframe for the report. Default is 30 days prior to today
= date.today()
as_of_date = 30
days_back
# 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.
= np.nan
top_n ############################
= as_of_date - timedelta(days=days_back)
report_from
= connect.Client()
client
# Get content usage data
= client.metrics.usage.find(
df =content_guid,
content_guid=report_from.strftime("%Y-%m-%dT%H:%M:%SZ"),
start=as_of_date.strftime("%Y-%m-%dT%H:%M:%SZ"))
end
= (pl.DataFrame(df, infer_schema_length=None)
usage "started").str.to_datetime("%Y-%m-%dT%H:%M:%SZ"))
.with_columns(pl.col("started").cast(pl.Date).alias("day"))) .with_columns(pl.col(
>>> print(usage)
10, 10)
shape: (
content_guid user_guid variant_key rendering_id bundle_id started ended data_version path daystr 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
<- "INSERT_CONTENT_GUID"
content_guid
# 2. Specify timeframe for the report. Default is 30 days prior to today
<- today()
as_of_date <- 30
days_back
# 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.
<- Inf
top_n ############################
<- as_of_date - ddays(days_back)
report_from
<- connect()
client
# Determine if the content type is Shiny or not. This dictates which telemetry endpoint to use
# Retrive content details
<- get_content(client, guid = content_guid)
content_info
# Determine type from the `app_mode`
<- content_info$app_mode
type
# Query appropriate telemetry endpoint for usage data
if (type == "shiny" || type == "python-shiny") {
<- get_usage_shiny(client,
usage content_guid = content_guid,
from = report_from,
to = as_of_date,
limit = Inf
|>
) mutate(day = floor_date(started, "day"))
else {
} <- get_usage_static(client,
usage 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
= pl.DataFrame(client.users.find())
all_users
# 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
= 99999
truncation_rows else:
= top_n
truncation_rows
# Make a table of a user's total usage
= (
user_tot_usage "user_guid")
usage.group_by(len().alias("tot_usage"))
.agg(pl.# change any `nan` user_guid to "anonymous"
"user_guid"), pl.lit("anonymous")).alias("user_guid"))
.with_columns(pl.coalesce(pl.col(
)
# Make a table of a user's daily usage and combine with total usage
= (
by_user_usage "user_guid","day"])
usage.group_by([len()
.agg(pl."daily_usage"))
.alias(="user_guid", right_on="guid", how="left")
.join(all_users, left_on"username"), pl.lit("anonymous")).alias("username"))
.with_columns(pl.coalesce(pl.col("user_guid"), pl.lit("anonymous")).alias("user_guid"))
.with_columns(pl.coalesce(pl.col(# join with total usage table
="user_guid", how="left")
.join(user_tot_usage, on"username", "day","tot_usage", "daily_usage"])
.select(["tot_usage",descending=True)
.sort(
.head(truncation_rows) )
>>> print(by_user_usage)
11, 4)
shape: (
username day tot_usage daily_usagestr 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
<- get_users(client, limit = Inf)
all_users
# Join user data with usage data
<- usage |>
by_user_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
-tables==0.4.0
great==1.0.0
jupyter==1.26.4
numpy==2.2.1
pandas==0.13.3
plotnine==0.20.18
polars-sdk==0.2.0
posit==15.0.2
pyarrow==6.0.1
PyYAML==69.2.0 setuptools
---
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
)
```