Skip to main content

flet-media-library

A high-performance Flet service extension for querying, displaying, saving, mutating, and monitoring the device media library from Python on Android and iOS.

The Python API is Flet-native, powered on Flutter by photo_manager for cross-platform querying, thumbnails, and saving photos/videos directly to public directories (DCIM/, Pictures/, Movies/), augmented with custom native Android Kotlin implementations for saving audio recordings directly to Music/, renaming assets, and relative directory moving.

PyPI - Version PyPI - Python Version License: MIT Platform Flet Author: Fazi Gondal Download Demo APK

Current Version: 1.0.2 | PyPI Package: flet-media-library

Developed & Maintained by: Fazi Gondal
Try the App: Download the pre-built Media Library Demo APK from GitHub Releases


Table of Contents


Key Features

  • Direct Public Media Ingestion: Save photos directly to DCIM/ and Pictures/, videos to Movies/ and DCIM/, and audio recordings directly to Music/.
  • Purpose-Specific Media Access: Compliant with Google Play and Apple App Store policies. Never asks for MANAGE_EXTERNAL_STORAGE.
  • Custom Android Kotlin Engine: Native fallbacks for operations unsupported by photo_manager: audio saving to Music/, in-place file renaming, and relative folder moving.
  • Fast Base64 Thumbnails: Efficiently generates thumbnails for both pictures and video frames directly into Flet ft.Image(src=...).
  • Album & Bucket Browsing: Fetch standard and custom user albums (Camera, Screenshots, Download, Music, WhatsApp, etc.).
  • Rich Filtering & Sorting: Sort by date added, date modified, size, duration, or filename, with pagination (limit, offset, has_more).
  • In-Place Gallery Mutations: Delete single or batch assets, rename files, copy assets between albums, and move assets across directories.
  • Live Change Events: Subscribe to real-time additions, deletions, or edits in the device media store.
  • Structured Error Handling: Dedicated typed exceptions (PermissionRequiredError, UnsupportedError, AssetNotFoundError, etc.).

Storage Destinations & Architecture

flet-media-library saves media directly into standard Android and iOS public directories via native platform MediaStore and PhotoKit APIs:

Operation Public Storage Destination Under the Hood Implementation Platform Support
Save Photo (save_image) DCIM/ or Pictures/ photo_manager (saveImageWithPath) Android & iOS
Save Video (save_video) Movies/ or DCIM/ photo_manager (saveVideo) Android & iOS
Save Audio (save_audio) Music/ (e.g. Music/Recordings/) Custom Native Kotlin (MediaStore.Audio) Android Only
Rename Asset (rename_asset) Renames display name in-place Custom Native Kotlin (MediaStore update) Android Only
Move Asset (move_asset) Moves between public albums/folders Custom Native Kotlin (RELATIVE_PATH update) Android Only
Delete Assets (delete_assets) Deletes from system gallery photo_manager (deleteWithIds) Android & iOS

Permissions Philosophy (No All-Files Access)

flet-media-library strictly follows the principle of least privilege. You do not need (and should never request) broad filesystem access or MANAGE_EXTERNAL_STORAGE for media operations.

Permission Matrix

Android API Version Permission Mechanism Purpose
Android 6 – 12 (API 23–32) READ_EXTERNAL_STORAGE (maxSdkVersion=32) Reads shared media library
Android 13+ (API 33+) READ_MEDIA_IMAGES
READ_MEDIA_VIDEO
READ_MEDIA_AUDIO
Granular permission for specific media types
Android 14+ (API 34+) READ_MEDIA_VISUAL_USER_SELECTED User grants access only to selected items
iOS 14+ PhotoKit Authorization Full, limited (partial), or denied access
Saving Media MediaStore / PhotoKit Insert APIs No broad write permission needed on modern systems

Android Manifest Declarations

This package automatically bundles the following permissions into your Android build manifest:

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />

iOS Info.plist Keys

When compiling for iOS (flet build ipa), declare usage descriptions for the photo library:

<key>NSPhotoLibraryUsageDescription</key>
<string>This app requires access to your photo library to browse and select media.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app requires permission to save photos and videos to your gallery.</string>

Installation & Quickstart

PyPI

pip install flet-media-library
# or using uv
uv add flet-media-library

Add to pyproject.toml

[project]
dependencies = [
    "flet>=1.0.0",
    "flet-media-library>=1.0.1",
]

Quickstart Example

import flet as ft
from flet_media_library import MediaLibrary

async def main(page: ft.Page):
    media = MediaLibrary()
    page.services.append(media)
    page.update()

    # Request permissions for images and videos
    status = await media.request_permissions(["image", "video"])
    if not status.all_granted and not status.any_limited:
        page.add(ft.Text("Media permission was denied!"))
        return

    # Query latest 20 photos
    page_data = await media.get_assets(media_type="image", limit=20)
    grid = ft.GridView(expand=True, max_extent=120, spacing=5, run_spacing=5)
    
    for asset in page_data.items:
        # Load thumbnail as base64
        thumb_b64 = await media.get_thumbnail(asset.id, width=120, height=120)
        grid.controls.append(
            ft.Image(src_base64=thumb_b64, fit=ft.BoxFit.COVER, border_radius=4)
        )

    page.add(grid)

if __name__ == "__main__":
    ft.run(main)

Demo App & APK Download

You can test flet-media-library immediately on your physical Android phone without setting up Flutter or Android development tools.

Pre-compiled, ready-to-sideload Android APKs (.apk) are automatically compiled and attached to every GitHub release:

👉 Download the latest Demo APK from GitHub Releases

Features in the Demo App:

  • Media Gallery: High-performance grid with base64 thumbnails for photos and videos.
  • In-App Media Player: Built-in video and audio playback dialogs (flet-video / ExoPlayer).
  • Camera & Microphone Capture: Live camera preview (flet-camera) and audio recorder (flet-audio-recorder) saving directly to gallery and Music/Recordings.
  • File Management: Move files between folders, rename with extensions, and batch-delete items.
  • Automated Smoke Test: 1-click test verifying all 8 core package APIs on actual hardware.

For instructions on building the demo from source, see examples/media_library_demo/README.md.


Comprehensive API Reference

Service Registration

MediaLibrary inherits from ft.Service. It must be added to page.services:

from flet_media_library import MediaLibrary

media = MediaLibrary()
page.services.append(media)
page.update()

Permissions APIs

check_permissions(media_types: list[str] | None = None) -> MediaPermissionStatus

Checks the current permission status without prompting the user.

  • Parameters: media_types — List of types to check: ["image", "video", "audio"] (defaults to all).
  • Returns: MediaPermissionStatus object with per-type status.
status = await media.check_permissions(["image", "video"])
print("Images status:", status["image"])  # "granted", "denied", "limited", etc.

request_permissions(media_types: list[str] | None = None) -> MediaPermissionStatus

Prompts the OS system permission dialog requesting access for the specified media types.

  • Parameters: media_types — ["image", "video", "audio"] (request only what you need).
  • Returns: MediaPermissionStatus.
status = await media.request_permissions(["image", "video", "audio"])
if status.all_granted:
    print("Full media library access granted")
elif status.any_limited:
    print("User granted limited/partial media access")

present_limited(media_types: list[str] | None = None) -> None

Re-opens the system limited-picker on iOS 14+ or Android 14+ so the user can select additional photos/videos without having to grant full library access.

await media.present_limited(["image", "video"])

open_settings() -> None

Navigates the user directly to the application's system settings screen (useful when permission is denied_forever).

await media.open_settings()

Album Queries

get_albums(media_type: str = "all") -> list[MediaAlbum]

Fetches albums/folders containing the specified media type.

  • Parameters: media_type — "all", "image", "video", or "audio".
  • Returns: list[MediaAlbum].
albums = await media.get_albums(media_type="image")
for album in albums:
    print(f"Album: {album.name} (ID: {album.id}) - {album.asset_count} items")

Asset Queries & Pagination

get_assets(...) -> MediaAssetPage

Queries assets with pagination, optional album filtering, and sorting.

async def get_assets(
    media_type: str = "all",           # "all" | "image" | "video" | "audio"
    album: str | None = None,           # Album ID from get_albums(), or None for root
    mime_type: str | None = None,       # Exact MIME filter (e.g. "video/mp4", Android only)
    limit: int = 50,                    # 1 to 500 items per page
    offset: int = 0,                    # Item offset to skip
    sort_by: str = "date_added",        # "date_added", "date_modified", "display_name", "size", "duration"
    sort_order: str = "desc",           # "desc" | "asc"
) -> MediaAssetPage
# Query page 1 (first 30 videos sorted newest first)
page1 = await media.get_assets(
    media_type="video",
    limit=30,
    offset=0,
    sort_by="date_added",
    sort_order="desc",
)

print(f"Total: {page1.total}, Loaded: {len(page1.items)}, Has more: {page1.has_more}")

# Query next page
if page1.has_more:
    page2 = await media.get_assets(media_type="video", limit=30, offset=30)

get_asset(asset_id: str) -> MediaAsset

Retrieves detailed metadata for a single specific asset by its ID.

asset = await media.get_asset("1000000032")
print(f"{asset.display_name} - {asset.width}x{asset.height} - {asset.size} bytes")

Thumbnails

get_thumbnail(asset_id: str, width: int = 200, height: int = 200, quality: int = 90) -> str

Generates a base64-encoded JPEG thumbnail for an image or a video frame.

  • Parameters:
    • asset_id: ID of the asset.
    • width: Desired thumbnail width in pixels.
    • height: Desired thumbnail height in pixels.
    • quality: JPEG compression quality (1–100).
  • Returns: Base64 string suitable for ft.Image(src_base64=...).
thumb_b64 = await media.get_thumbnail(asset.id, width=150, height=150, quality=85)
image_ctrl = ft.Image(src_base64=thumb_b64, width=150, height=150)

Saving Media (Images, Videos, Audio)

Saves local files directly into the platform media gallery using platform MediaStore and PhotoKit insert pipelines. No broad storage permissions required.

save_image(file_path: str, file_name: str | None = None, album: str | None = None) -> MediaAsset

Saves an image file directly into public gallery storage (Pictures/ or DCIM/).

  • Public Destinations:
    • Pictures/<Subfolder> (e.g. album="Pictures/MyApp")
    • DCIM/<Subfolder> (e.g. album="DCIM/Camera")
  • Backend: Uses upstream photo_manager (saveImageWithPath).
  • Platform Support: Android & iOS.
  • Parameters:
    • file_path: Absolute path to source image (e.g. app scratch file or downloaded photo).
    • file_name: Optional target filename (e.g. "snapshot_2026.jpg").
    • album: Optional public album folder name (defaults to "Pictures/FletMediaLibrary").
asset = await media.save_image(
    "/data/user/0/com.app/cache/photo.jpg",
    file_name="snapshot_2026.jpg",
    album="Pictures/MyCameraApp",
)
print("Saved image ID:", asset.id)

save_video(file_path: str, file_name: str | None = None, album: str | None = None) -> MediaAsset

Saves a video file directly into public gallery storage (Movies/ or DCIM/).

  • Public Destinations:
    • Movies/<Subfolder> (e.g. album="Movies/MyVideoApp")
    • DCIM/<Subfolder> (e.g. album="DCIM/Camera")
  • Backend: Uses upstream photo_manager (saveVideo).
  • Platform Support: Android & iOS.
  • Parameters:
    • file_path: Absolute path to source video file.
    • file_name: Optional target filename (e.g. "clip.mp4").
    • album: Optional public folder name (defaults to "Movies/FletMediaLibrary").
asset = await media.save_video(
    "/path/to/recording.mp4",
    file_name="clip.mp4",
    album="Movies/MyCameraApp",
)

save_audio(file_path: str, file_name: str | None = None, album: str | None = None) -> MediaAsset

Saves an audio file directly into the device's public Music/ folder.

  • Public Destination: Music/<Subfolder> (e.g. album="Music/Recordings" or album="Music/MyPodcasts").
  • Backend: Custom Native Android Kotlin Implementation (FletMediaLibraryPlugin.kt writing directly to MediaStore.Audio.Media). Upstream photo_manager does not support saving audio files.
  • Platform Support: Android Only (raises UnsupportedError on iOS).
  • Parameters:
    • file_path: Absolute path to source audio file (e.g. .m4a, .mp3, .wav, .aac).
    • file_name: Target filename (e.g. "recording_01.m4a").
    • album: Target subfolder within Music (defaults to "Music/FletMediaLibrary").
asset = await media.save_audio(
    "/path/to/voice_note.m4a",
    file_name="recording_01.m4a",
    album="Music/Recordings",
)
print(f"Audio indexed in Music/Recordings: {asset.display_name} (ID: {asset.id})")

Mutations (Rename, Move, Copy, Delete)

delete_asset(asset_id: str) -> bool

Deletes a single asset from the device gallery.

  • Backend: photo_manager (deleteWithIds).
  • Platform Support: Android & iOS.
  • Returns: True if successfully deleted, False if user cancelled system dialog.
ok = await media.delete_asset(asset.id)
if ok:
    print("Asset deleted successfully")

delete_assets(asset_ids: list[str]) -> list[str]

Batch deletes multiple assets in a single native system confirmation.

  • Backend: photo_manager (deleteWithIds).
  • Platform Support: Android & iOS.
  • Returns: List of asset IDs that were successfully deleted.
deleted_ids = await media.delete_assets(["id1", "id2", "id3"])
print(f"Deleted {len(deleted_ids)} items")

rename_asset(asset_id: str, new_name: str) -> bool

Renames an asset's display name and filename directly in the device MediaStore.

  • Backend: Custom Native Android Kotlin Implementation (FletMediaLibraryPlugin.kt). Upstream photo_manager does not support renaming assets.
  • Platform Support: Android Only (raises UnsupportedError on iOS). On Android 11+, the OS may present a native consent dialog for non-owned media.
  • Parameters:
    • asset_id: The MediaStore ID of the item.
    • new_name: New filename with extension (e.g. "vacation_sunset.jpg").
ok = await media.rename_asset(asset.id, "vacation_sunset.jpg")

move_asset(asset_id: str, target_relative_path: str) -> bool

Moves an asset between public folders (e.g., moving from DCIM/Camera to Pictures/Archive or Movies/Archive).

  • Backend: Custom Native Android Kotlin Implementation (RELATIVE_PATH MediaStore update). Upstream photo_manager does not support folder relocation.
  • Platform Support: Android 10+ Only (raises UnsupportedError on iOS or older Android versions).
  • Parameters:
    • asset_id: The MediaStore ID of the item.
    • target_relative_path: Target public relative path (e.g. "Pictures/Archive").
ok = await media.move_asset(asset.id, "Pictures/Archive")
if ok:
    print("Moved to archive folder")

copy_asset(asset_id: str, target_album: str) -> MediaAsset

Copies an asset into a target album. Platform support varies:

  • Android < 11: Creates duplicate file.
  • Android 11+: Scoped storage restricts arbitrary duplication; raises UnsupportedError.
  • iOS: Links asset to the target album.

Live Media Change Notifications

Subscribe to system media library changes (such as when new photos are taken by the camera or downloaded).

def on_media_changed(e: ft.ControlEvent):
    event = e.data  # MediaChangeEvent
    print(f"Change detected: type={event.change_type}, id={event.asset_id}")

media.on_change = on_media_changed
await media.start_change_notify()

# When finished or exiting screen:
await media.stop_change_notify()

Cache Management

clear_file_cache() -> None

Clears internal thumbnail and file caches cached by the underlying plugin.

await media.clear_file_cache()

Data Models

MediaAsset

Represents a single media item:

Field Type Description
id str Platform-unique asset identifier
display_name str File name including extension (e.g. IMG_001.jpg)
mime_type str MIME type (e.g. image/jpeg, video/mp4, audio/mp4)
media_type str Broad type: "image", "video", or "audio"
size int Size in bytes
width int Pixel width (0 for audio)
height int Pixel height (0 for audio)
duration_ms int Duration in milliseconds (0 for images)
date_added int Unix timestamp (seconds) when added to library
date_modified int Unix timestamp (seconds) when last modified
orientation int EXIF orientation angle (0, 90, 180, 270)
album_id str Containing album identifier
album_name str Containing album name
relative_path str Relative directory (e.g. DCIM/Camera/)
source_uri str System URI (content://... on Android, ph://... on iOS)

MediaAlbum

Represents an album, bucket, or folder:

Field Type Description
id str Album unique identifier
name str User-facing album title (e.g. Camera, Screenshots)
asset_count int Count of media items in this album
media_types list[str] Media types present in album (["image", "video"])
is_all bool True if this is the "Recent" / "All Media" collection
is_system_album bool True for OS-managed collections
platform_identifier str Native platform identifier

MediaPermissionStatus

Snapshot of permission states:

Property / Method Type Description
states dict[str, str] Maps media type to status (granted, limited, denied, denied_forever, restricted, unknown)
status[media_type] str Shortcut for accessing status by key (e.g. status["image"])
all_granted bool True if all requested types are "granted"
any_limited bool True if any requested type is "limited"
can_request bool True if system dialog can still be requested

MediaAssetPage

Result of a paginated get_assets query:

Field Type Description
items list[MediaAsset] List of MediaAsset items for this page
total int Total count matching filter
offset int Current query offset
limit int Items per page
has_more bool True if more items are available

Exception Hierarchy

All exceptions inherit from MediaLibraryError:

MediaLibraryError
 ├── PermissionRequiredError   # Operation attempted before permissions were granted
 ├── PermissionDeniedError     # User actively denied permission
 ├── UnsupportedError          # Method unsupported on this OS or OS version
 ├── AssetNotFoundError        # Target asset ID does not exist
 ├── AlbumNotFoundError        # Target album ID does not exist
 ├── InvalidArgumentError      # Bad arguments (e.g. limit > 500, negative offset)
 └── PlatformError             # Underlying native platform exception

Practical Cookbooks & Examples

Cookbook 1: Request Permissions & Load Thumbnail Grid

import flet as ft
from flet_media_library import MediaLibrary, PermissionRequiredError

async def main(page: ft.Page):
    media = MediaLibrary()
    page.services.append(media)
    page.update()

    status = await media.request_permissions(["image", "video"])
    if not (status.all_granted or status.any_limited):
        page.add(ft.Text("Permissions denied. Cannot browse media."))
        return

    grid = ft.GridView(expand=True, max_extent=110, spacing=4, run_spacing=4)
    page.add(grid)

    asset_page = await media.get_assets(media_type="image", limit=40)
    for asset in asset_page.items:
        thumb = await media.get_thumbnail(asset.id, width=110, height=110)
        grid.controls.append(
            ft.Image(src_base64=thumb, fit=ft.BoxFit.COVER, border_radius=4)
        )
    page.update()

ft.run(main)
import flet as ft
from flet_media_library import MediaLibrary

async def save_captured_media(media: MediaLibrary, photo_file: str, video_file: str, audio_file: str):
    # 1. Save photo directly to public Pictures/ or DCIM/ (cross-platform via photo_manager)
    photo_asset = await media.save_image(
        photo_file,
        file_name="snapshot_2026.jpg",
        album="Pictures/MyCameraApp",  # or "DCIM/Camera"
    )
    print(f"Photo saved into Pictures: {photo_asset.display_name} (ID: {photo_asset.id})")

    # 2. Save video directly to public Movies/ or DCIM/ (cross-platform via photo_manager)
    video_asset = await media.save_video(
        video_file,
        file_name="clip_2026.mp4",
        album="Movies/MyCameraApp",    # or "DCIM/Camera"
    )
    print(f"Video saved into Movies: {video_asset.display_name} (ID: {video_asset.id})")

    # 3. Save audio directly to public Music/ (Android-only via custom Kotlin engine)
    audio_asset = await media.save_audio(
        audio_file,
        file_name="voice_note.m4a",
        album="Music/Recordings",      # Saved directly under Music/
    )
    print(f"Audio indexed into Music: {audio_asset.display_name} (ID: {audio_asset.id})")

Cookbook 3: Moving and Renaming Assets (Android)

from flet_media_library import MediaLibrary, UnsupportedError

async def organize_media(media: MediaLibrary, asset_id: str):
    try:
        # Rename file
        renamed = await media.rename_asset(asset_id, "family_vacation_2026.jpg")
        print("Renamed:", renamed)

        # Move to Archive folder
        moved = await media.move_asset(asset_id, "Pictures/Archive")
        print("Moved:", moved)
    except UnsupportedError as err:
        print("Operation not supported on this platform/OS version:", err)

Cookbook 4: Real-time Change Monitoring

async def watch_gallery(page: ft.Page, media: MediaLibrary):
    async def on_change(e):
        ev = e.data
        print(f"Library updated! Type: {ev.change_type}, Asset: {ev.asset_id}")
        # Refresh UI
        page.snack_bar = ft.SnackBar(ft.Text("Media gallery changed!"))
        page.snack_bar.open = True
        page.update()

    media.on_change = on_change
    await media.start_change_notify()

Building Packaged Mobile Apps

When packaging with Flet, the Flutter engine plugin is automatically linked into the mobile binary.

Android (APK)

uv run flet build apk \
  --split-per-abi \
  --arch arm64-v8a \
  --permissions camera microphone \
  --yes

To build against a local development version of this repository:

uv run flet build apk \
  --source-packages /path/to/flet_media_library/src/flutter/flet_media_library \
  --permissions camera microphone \
  --yes

iOS (IPA / Simulator)

# Simulator build
uv run flet build ios-simulator --permissions camera microphone --yes

# Production IPA
uv run flet build ipa \
  --permissions camera microphone \
  --info-plist NSPhotoLibraryUsageDescription="Browse and select media from device gallery" \
  --info-plist NSPhotoLibraryAddUsageDescription="Save photos and captured media to library"

Local Development Setup

To run and contribute to flet-media-library:

git clone https://github.com/fazi-gondal/Flet-media-library.git
cd Flet-media-library

# Install Python package in editable mode
uv sync

# Test Python models & serialization
uv run pytest tests/

# Format Dart plugin code
cd src/flutter/flet_media_library
flutter pub get
flutter analyze
dart format --set-exit-if-changed --output=none .

To explore and test on a physical device or emulator, see the full-featured test harness in examples/media_library_demo/.


Credits & Acknowledgments


License

This project is licensed under the MIT License — see the LICENSE file for details.

Release files for flet-media-library 1.0.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for flet-media-library 1.0.2
File Size Uploaded
flet_media_library-1.0.2.tar.gz 49.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for flet-media-library 1.0.2
File Interpreter ABI Platform
flet_media_library-1.0.2-py3-none-any.whl Python 3 none any Details

Total release size: 87.6 kB

Release files / flet_media_library-1.0.2.tar.gz

Download URL flet_media_library-1.0.2.tar.gz
Size 49.4 kB
Tags Source
SHA-256 checksum
How to use checksums
b55e73a77824297e96122da22c3cd0adcf00036e3f881a5e3d5bcbdf8525d381
BLAKE2b-256 checksum
How to use checksums
e6d74eb2f2bba498ec6be355cb3bd0a0b3ffbe4f9568932ebde367a96fd478b0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release files / flet_media_library-1.0.2-py3-none-any.whl

Download URL flet_media_library-1.0.2-py3-none-any.whl
Size 38.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ae9e8e3f88f083dca1b921fda8cce3d4b587a4902dda9f969aafd25821ab53f1
BLAKE2b-256 checksum
How to use checksums
1dc1ffad92bc05eb18be968a4a67173f9cc519588b4c05aebc522931e5034aa9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release history Release notifications | RSS feed

1.1.2

2 release files

This release

1.0.2 This release

2 release files

1.0.1

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page