Skip to content

Initial app-canary #156

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/extensions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jobs:
top-5-income-share-streamlit: extensions/top-5-income-share-streamlit/**
stock-report-jupyter: extensions/stock-report-jupyter/**
usage-metrics-dashboard: extensions/usage-metrics-dashboard/**
app-canary: extensions/app-canary/**
voila-example: extensions/voila-example/**

# Runs for each extension that has changed from `simple-extension-changes`
Expand Down
13 changes: 13 additions & 0 deletions extensions/app-canary/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/.quarto/

/_*.local

app-canary.html

/email-preview

.output_metadata.json

preview.html

/.posit/
18 changes: 18 additions & 0 deletions extensions/app-canary/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# App Canary

A Quarto dashboard that tests one or more applications that have been deployed to Connect and validates if the app is
successfully running and displays the results.

# Setup

The following environment variables are required

```bash:
CONNECT_SERVER # Set automatically on Connect, otherwise set to your Connect server URL
CONNECT_API_KEY # Set automatically on Connect, otherwise set to your Connect API key
CANARY_GUIDS # Comma separated list of GUIDs for the applications to test
```

# Usage

Deploy the app to Connect and then render the dashboard.
5 changes: 5 additions & 0 deletions extensions/app-canary/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
project:
title: "App Canary"



222 changes: 222 additions & 0 deletions extensions/app-canary/app-canary.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
---
title: "App Canary - Application Health Monitor"
format: email
---

```{python}
#| echo: false

import os
import requests
import datetime
import pandas as pd
from great_tables import GT, style, loc, exibble, html

# Used to display on-screen setup instructions if environment variables are missing
show_instructions = False
instructions = []
gt_tbl = None

# Read CONNECT_SERVER from environment, should be configured automatically when run on Connect
connect_server = os.environ.get("CONNECT_SERVER", "")
if not connect_server:
show_instructions = True
instructions.append("Please set the CONNECT_SERVER environment variable.")

# Read CONNECT_API_KEY from environment, should be configured automatically when run on Connect
api_key = os.environ.get("CONNECT_API_KEY", "")
if not api_key:
show_instructions = True
instructions.append("Please set the CONNECT_API_KEY environment variable.")

# Read CANARY_GUIDS from environment, needs to be manually configured on Connect
app_guid_str = os.environ.get("CANARY_GUIDS", "")
if not app_guid_str:
show_instructions = True
instructions.append("Please set the CANARY_GUIDS environment variable. It should be a comma separated list of GUID you wish to monitor.")
app_guids = []
else:
# Clean up the GUIDs
app_guids = [guid.strip() for guid in app_guid_str.split(',') if guid.strip()]
if not app_guids:
show_instructions = True
instructions.append("CANARY_GUIDS environment variable is empty or contains only whitespace. It should be a comma separated list of GUID you wish to monitor. Raw CANARY_GUIDS value: '{app_guid_str}'")

if show_instructions:
# We'll use this flag later to display instructions instead of results
results = []
df = pd.DataFrame() # Empty DataFrame
check_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
else:
# Continue with normal execution
# Headers for Connect API
headers = {"Authorization": f"Key {api_key}"}

# Check if server is reachable
try:
server_check = requests.get(
f"{connect_server}/__ping__",
headers=headers,
timeout=5
)
server_check.raise_for_status()
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Connect server at {connect_server} is unavailable: {str(e)}")

# Function to get app details from Connect API
def get_app_details(guid):
try:
# Get app details from Connect API
app_details_url = f"{connect_server}/__api__/v1/content/{guid}"
app_details_response = requests.get(
app_details_url,
headers=headers,
timeout=5
)
app_details_response.raise_for_status()
return app_details_response.json()
except Exception:
return {"title": "Unknown", "guid": guid}

# Function to validate app health (simple HTTP 200 check)
def validate_app(guid):
# Get app details
app_details = get_app_details(guid)
app_name = app_details.get("title", "Unknown")

# Extract content_url if available
dashboard_url = app_details.get("dashboard_url", "")

try:
app_url = f"{connect_server}/content/{guid}"
app_response = requests.get(
app_url,
headers=headers,
timeout=60, # Max time to wait for a response from the content
allow_redirects=True # Enabled by default in Python requests, included for clarity
)

return {
"guid": guid,
"name": app_name,
"dashboard_url": dashboard_url,
"status": "PASS" if app_response.status_code >= 200 and app_response.status_code < 300 else "FAIL",
"http_code": app_response.status_code
}

except Exception as e:
return {
"guid": guid,
"name": app_name,
"dashboard_url": dashboard_url,
"status": "FAIL",
"http_code": str(e)
}

# Check all apps and collect results
results = []
for guid in app_guids:
results.append(validate_app(guid))

# Convert results to DataFrame for easy display
df = pd.DataFrame(results)

# Reorder columns to put name first
# Create a dynamic column order with name first, status and http_code last
if 'name' in df.columns:
cols = ['name'] # Start with name
# Add any other columns except name, status, and http_code
middle_cols = [col for col in df.columns if col not in ['name', 'status', 'http_code']]
cols.extend(middle_cols)
# Add status and http_code at the end
if 'status' in df.columns:
cols.append('status')
if 'http_code' in df.columns:
cols.append('http_code')
# Reorder the DataFrame
df = df[cols]

# Store the current time
check_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
```


```{python}
#| echo: false

# Create a table with basic styling
if not show_instructions and not df.empty:

# First create links for name and guid columns
df_display = df.copy()

# Process the DataFrame rows to add HTML links
for i in range(len(df_display)):
if not pd.isna(df_display.loc[i, 'dashboard_url']) and df_display.loc[i, 'dashboard_url']:
url = df_display.loc[i, 'dashboard_url']
df_display.loc[i, 'name'] = f"<a href='{url}' target='_blank'>{df_display.loc[i, 'name']}</a>"
df_display.loc[i, 'guid'] = f"<a href='{url}' target='_blank'>{df_display.loc[i, 'guid']}</a>"

# Remove dashboard_url column since the links are embedded in the other columns
if 'dashboard_url' in df_display.columns:
df_display = df_display.drop(columns=['dashboard_url'])

# Create GT table
gt_tbl = GT(df_display)

# Apply styling to status column
gt_tbl = (gt_tbl
.tab_style(
style.fill("green"),
locations=loc.body(columns="status", rows=lambda df: df["status"] == "PASS")
)
.tab_style(
style.fill("red"),
locations=loc.body(columns="status", rows=lambda df: df["status"] == "FAIL")
))

# Display instructions if setup failed
if show_instructions:
# Create a DataFrame with instructions
instructions_df = pd.DataFrame({
"Setup has failed": instructions
})

# Create a GT table for instructions
gt_tbl = GT(instructions_df)
gt_tbl = (gt_tbl
.tab_source_note(
source_note=html("See Posit Connect documentation for <a href='https://docs.posit.co/connect/user/content-settings/#content-vars' target='_blank'>Vars (environment variables)</a>")
))

# Compute if we should send an email, only send if at least one app has a failure
if 'df' in locals() and 'status' in df.columns:
send_email = bool(not df.empty and (df['status'] == 'FAIL').any())
else:
send_email = False
```


```{python}
#| echo: false

# Display the table in the rendered document HTML, email is handled separately below
gt_tbl
```


::: {.email}

::: {.email-scheduled}
`{python} send_email`
:::

::: {.subject}
App Canary - ❌ one or more apps have failed monitoring
:::

```{python}
#| echo: false
gt_tbl
```
:::
47 changes: 47 additions & 0 deletions extensions/app-canary/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"version": 1,
"locale": "en_US.UTF-8",
"metadata": {
"appmode": "quarto-static"
},
"extension": {
"name": "app-canary",
"title": "App Canary",
"description": "Provides a way to monitor deployed content and optionally email a report when any of the content has failed.",
"homepage": "https://github.com/posit-dev/connect-extensions/tree/main/extensions/app-canary",
"category": "extension",
"tags": [],
"minimumConnectVersion": "2025.04.0",
"version": "0.0.0"
},
"environment": {
"python": {
"requires": "~=3.11"
},
"quarto": {
"requires": "~=1.4"
}
},
"quarto": {
"version": "1.4.557",
"engines": [
"jupyter"
]
},
"python": {
"version": "3.11.7",
"package_manager": {
"name": "pip",
"version": "23.2.1",
"package_file": "requirements.txt"
}
},
"files": {
"requirements.txt": {
"checksum": "09254fc2dfa7d869ffbc3da2abb1d224"
},
"app-canary.qmd": {
"checksum": "732af0f4ea846c37d3a7641c3bbd6ba3"
}
}
}
6 changes: 6 additions & 0 deletions extensions/app-canary/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# requirements.txt auto-generated by Posit Publisher
ipython==8.12.3
great_tables
pandas==2.2.3
requests==2.31.0
css-inline==0.14.6