Skip to main content

Selenium Query

Selenium Query is an augmented wrapper around python-selenium, inspired by jQuery's collection-oriented model. Its main goal is to hide technicalities when writing tests, to obtain a more declarative code.

Selenium Query is heavily inspired by the way jQuery manipulates collections of elements: instead of thinking about one Selenium WebElement at a time, the library lets you build queries representing collections of matching elements and apply operations to them as a whole.

The important distinction is therefore:

selenium query → collection of matching elements → operation on the collection

This is closer to:

$(".button").click()

than to manually retrieving and manipulating one element at a time.

The main user-facing entry point is BaseSeleniumPage.find(...).

Users should generally not instantiate Getter directly. Getter is the central query object returned by the page API and provides access to the other query behaviors.

You may find below a very short overview of the possibilities. See the implementation for more details.


BaseSeleniumPage

This is the normal entry point for using Selenium Query.

A page object inherits from BaseSeleniumPage and uses find(...) to create queries:

from selenium_query import BaseSeleniumPage


class MyPage(BaseSeleniumPage):

    def click_submit(self):
        self.find("#submit").click().go
        # .go is an alias for Selenium ActionChain.perform()

find(...) internally creates a page-scoped query:

self.find("#submit") # -> Getter

This is equivalent to using the underlying Getter.find(..., in_page=True) mechanism, but BaseSeleniumPage.find(...) is the normal user-facing API.

BaseSeleniumPage also provides the page-level Selenium helpers used by the package, such as:

MyPage.wait_for_element("#submit")
MyPage.wait_until(lambda driver: driver.title == "Ready")
MyPage.run_js("return arguments[0] + arguments[1]", 1, 2)

In normal usage, page classes inherit from BaseSeleniumPage and expose application-specific operations.


Getter

Getter is the central query object.

Again, the usual way to obtain one is:

query: Getter = MyPage.find(".item")

not:

Getter(...)

A Getter represents the current collection of matching elements and provides access to the other query behaviors:

  • It relays most of the usual selenium properties/methods, as well as some "extras":

    Observers :

    id
    class_                  - extra
    tag_name
    text
    rect
    is_enabled()
    is_selected()
    is_displayed()
    exists()                - extra
    count(n)                - extra
    

    Note: when used without "helpers" properties (see below), these behave like predicates. See implementation details.

    It also makes most of the ActionChain properties/methods (also with extras) directly accessible from the Getter object, Selenium Query handling the underlying logistic fore the user:

    go                      - extra / alias for perform(), as a @property
    reset_actions
    swap_duration           - extra / context manager
    context_chain_and_go    - extra / context manager
    click
    click_and_hold
    context_click
    double_click
    drag_and_drop
    drag_and_drop_by_offset
    key_down
    key_up
    send_keys_to_element
    send_keys
    send_shortcut           - extra
    move_by_offset
    move_to_element
    move_to                 - extra / alias for move_to_element
    move_to_element_with_offset
    release
    pause
    scroll_to               - behaves differently from selenium
    __getitem__             - extra (index or slice)
    
  • It also adds its own helpers, to change the behaviors of the main observers (see above):

    items = MyPage.find(".item")
    
    items.get       # extract values from the collection
    items.check     # assert various things about the elements of the collections
    items.filter    # filter the current collection (new instance)
    items.order_by  # sort the collection (new instance)
    items.map       # build a collection of collections (MapperGetter)
    items.any       # verify at least one element in the collection matches a condition
    items.all       # verify all elements in the collection match a condition
    items.not_      # revert the behavior (for check.not_, filter.not_, all.not_ or not_.all, ...)
    

For example (each method is a different way to use selenium query objects):

class MyPage(BaseSeleniumPage):

    def get_items_contents(self):
        return self.find(".item").get.text

    def test_items_count(self):
        self.find(".item").check.count(4)

    def test_items_contents(self):
        self.find(".item").check.text(['a', 'b', 'c', 'd'])

Getter also supports merging collections:

first = MyPage.find(".first")
second = MyPage.find(".second")

merged = first.merge(second, ...)
# or as a classmethod:
merged2 = Getter.merge_cls(first, second, ...)

The resulting query contains the elements from all collections passed as argument.


ValuesGetter

ValuesGetter is accessed through the .get property:

items = MyPage.find(".item")

texts = items.get.text
ids = items.get.id
classes = items.get.class_
...

For example:

class MyPage(BaseSeleniumPage):

    def item_texts(self):
        return self.find(".item").get.text

    def item_ids(self):
        return self.find(".item").get.id

The available value accessors include:

query.get.id
query.get.class_
query.get.tag_name
query.get.text
query.get.rect
query.get.is_enabled()
query.get.is_selected()
query.get.is_displayed()
query.get.count()

CSS properties, data-* attributes and arbitrary properties can also be extracted:

query.get.css("display", "color")
query.get.data("status")
query.get.props("href", "class")

For example:

class MyPage(BaseSeleniumPage):

    def statuses(self):
        return self.find(".item").get.data("status")

The output shape depends on the number of matched elements and requested values:

one element + one value      → value
many elements + one value    → list of values
many values requested        → dictionary or list of dictionaries

AsserterGetter

AsserterGetter is accessed through .check.

It performs assertions on the current collection and returns the query, allowing assertions to be chained.

For example:

class MyPage(BaseSeleniumPage):

    def check_page(self):
        self.find(".item").check.exists()

A value can be checked against every element:

class MyPage(BaseSeleniumPage):

    def check_items(self):
        self.find(".item").check.text("Expected text")

Different expected values for various elements of the collection can also be supplied with a list/tuple of appropriate length:

self.find(".item").check.text(
    ["First item", "Second item", "Third item"]
)

A custom assertion message can be added by calling .check(...):

self.find(".item").check(
    "The item list should contain the expected entries."
).text(
    ["First item", "Second item"]
)

The .check API is particularly useful when the test needs to validate a collection as a whole rather than manually retrieving every WebElement.


FilterGetter

FilterGetter is accessed through .filter.

It transforms a collection into a new collection containing only the matching elements.

For example:

class MyPage(BaseSeleniumPage):

    def active_items(self):
        return self.find(".item").filter.data(status="active")

The result is another query containing only elements whose data-status value matches "active".

The same filtering mechanism can be used with other value-based accessors, such as:

self.find(".item").filter.id("item-1")
self.find(".item").filter.class_("selected")
self.find(".item").filter.text("Expected text")

Custom predicates are also supported:

class MyPage(BaseSeleniumPage):

    def visible_items(self):
        return self.find(".item").filter(
            lambda item: item.get.text.startswith("specifics")
        )

The predicate receives a Getter for each individual element of the original collection


OrderGetter

OrderGetter is accessed through .order_by.

It sorts the current collection and returns a new query with the elements reordered.

For example:

class MyPage(BaseSeleniumPage):

    def items_alphabetically(self):
        return self.find(".item").order_by.text

The ordering can also use CSS properties, data attributes or other values:

self.find(".item").order_by.data("priority")

A custom key function can be provided:

class MyPage(BaseSeleniumPage):

    def items_by_text_length(self):
        return self.find(".item").order_by(
            lambda item: len(item.get.text)
        )

The important point is that .order_by changes the order of the query's collection, not the order of the elements in the page itself.


MapperGetter

MapperGetter is accessed through .map.

It is useful for hierarchical DOM structures: take a collection of parent elements and apply a query to each parent.

For example, given:

<div class="card">
    <span class="title">First</span>
</div>

<div class="card">
    <span class="title">Second</span>
</div>

the following query maps .title inside each card:

titles = self.find(".card").map(".title")

The resulting MapperGetter behaves like a collection of queries, one per original .card (these are Getter objects).

Values can then be extracted from every mapped result:

titles.get.text
class MyPage(BaseSeleniumPage):

    def card_titles(self):
        return (
            self.find(".card")
                .map(".title")
                .get.text
        )

A mapper can also transmit operations to each mapped query:

self.find(".card").map(".title").check.text(
    ["First", "Second"]
)

The above example is not very useful, as this could be done with a simple find(".card .title").check(["First", "Second"]), but this becomes especially useful when each Getter sub element has its own internal hierarchical structure.

MapperGetter is intended for hierarchical collections. It is not a general-purpose replacement for ordinary Getter queries.


BoolAllGetter

BoolAllGetter is accessed through .all.

It evaluates a boolean condition over the collection and combines the results with all(...). This answers the question:

Are all matching elements displayed?

For example:

class MyPage(BaseSeleniumPage):

    def all_items_are_visible(self):
        return self.find(".item").all.is_displayed()

The .all accessor is useful when the condition must be true for every element in the current collection.

Note that, when no behavior property is given (get, check, ...), the Getter behaves as if all was used.


BoolAnyGetter

BoolAnyGetter is accessed through .any.

It evaluates a boolean condition over the collection and combines the results with any(...).

For example:

class MyPage(BaseSeleniumPage):

    def has_a_selected_item(self):
        return self.find(".item").any.is_selected()

NotTransmitter

NotTransmitter is accessed through .not_.

It negates the next boolean or assertion behavior.

For example:

class MyPage(BaseSeleniumPage):

    def no_item_is_selected(self):
        return self.find(".item").not_.any.is_selected()

This expresses:

It is not the case that any item is selected.

It can also be used with assertions:

self.find(".item").check.not_.exists()

The not_ accessor is intended as a negation layer over the following query operation. It can not be followed with .map or .order_by.

Also, it behaves differently when used "around" the boolean accessors:

  • .all.not_.predicate is all( not predicate(elt) for elt in collection)
  • .not_.all.predicate is not all( predicate(elt) for elt in collection)
  • .any.not_.predicate is any( not predicate(elt) for elt in collection)
  • .not_.any.predicate is not any( predicate(elt) for elt in collection)

In this regard, getter.not_.predicate behaves like getter.all.not_.predicate.


A complete example

Putting the main pieces together (but leaving aside the logistic related to the BaseSeleniumPage extension):

from selenium_query import BaseSeleniumPage


class ProductsPage(BaseSeleniumPage):

    def product_names(self):
        return self.find(".product").get.text

    def active_products(self):
        return (
            self.find(".product")
                .filter
                .data(status="active")
        )

    def products_by_name(self):
        return self.find(".product").order_by.text

    def product_titles(self):
        return (
            self.find(".product")
                .map(".title")
                .get.text
        )

    def check_products(self):
        (
            self.find(".product")
                .check
                .exists()
        )

    def has_visible_product(self):
        return self.find(".product").any.is_displayed()

    def all_products_visible(self):
        return self.find(".product").all.is_displayed()

    def no_product_is_selected(self):
        return self.find(".product").not_.any.is_selected()

The general model is:

BaseSeleniumPage
        │
        └── find(...)
                │
              Getter
                │
        ┌───────┼────────┬────────┬────────┬────────┬────────┬────────┬────────┐
        │       │        │        │        │        │        │        │        │
       get    check    actions  filter    all      any      not_     map    order_by
        │
     values

The most important thing to remember is that Getter is the central abstraction, but the user normally reaches it through:

page.find("...")

The library then provides specialized views of that same collection:

query.get
query.check
query.filter
query.map
query.order_by
query.any
query.all
query.not_

This is the core of the jQuery-like collection model: select once, then transform, inspect, assert, order or map the resulting collection.





BaseSeleniumPage: the page-level entry point

BaseSeleniumPage is the main entry point for using Selenium Query.

A page object should inherit from it:

from selenium_query import BaseSeleniumPage


class MyPage(BaseSeleniumPage):
    URL = "my-page"

The class centralizes the Selenium objects and page-level operations needed by the tests:

  • the current WebDriver;
  • the WebDriverWait instance;
  • the root Getter used to create queries;
  • page loading and readiness;
  • data gathering;
  • CSS-based element lookup;
  • alert handling;
  • JavaScript execution;
  • test dependencies and execution tracking.

The important relationship is:

BaseSeleniumPage
        │
        ├── driver
        ├── waiter
        └── Getter
              │
              └── find(...)
                    │
                 query

In normal user code, the query API starts with:

self.find("...")

rather than by manually instantiating a Getter.


Defining a page

A subclass declares the relative URL of the page it represents:

class SearchPage(BaseSeleniumPage):
    URL = "search"

The URL is combined with the server address supplied by the Selenium environment:

server address + URL

For example:

http://localhost:8000/ + search
→ http://localhost:8000/search

The URL must be relative to the server root and must not start with docs/.

A page can customize how its final address is built by overriding:

@classmethod
def get_address_from_class_url(cls, server_address):
    return server_address + cls.URL

This is useful when the URL needs custom construction.


Loading a page

BaseSeleniumPage separates page loading from page-specific data gathering.

The expected model is:

load_url()
    │
    ├── load the URL
    ├── wait until the page is ready
    └── gather shared page data

data()
    │
    └── build data specific to the subclass

The base load_url and data methods are intentionally abstract and should be overloaded as pytest fixtures:

@pytest.fixture(scope='module' autouse=True)
@classmethod
def load_url(cls, clean_cache, selenium_env):
    cls._load_url(selenium_env)

@pytest.fixture(scope='class' autouse=True)
@classmethod
def data(cls, load_url):
    cls._build_data()

Here:

  • clear_cache is a fixture (you shouldn't need to interact with it)

  • selenium_env has to be a 3-uples: (stdout:str, server_address:str, driver:WebDriver).

    • stdout is not used in Selenium Query and is present as a convenience for the user. It can be None if not needed.
    • server_address is a string giving the address of the current live server (something like http://localhost:8000/). It should end with a slash.
    • driver is the selenium WebDriver instance which will be used during the tests.

A concrete page class therefore needs to provide the appropriate fixture implementations for its test environment, with the appropriate scope levels, the initial intent for this being to test:

  • One URL per python module
  • Split the tests over different subclasses, each one gathering the data it needs.

An important point is that _load_url is the shared implementation, while subclasses decide how it is exposed to their test/fixture system.

Various methods can be overloaded in the class extending BaseSeleniumPage, to easily change the behaviors to match the project's needs:

class SearchPage(BaseSeleniumPage):

    URL: ClassVar[str]

    @classmethod
    def get_address_from_class_url(cls, server_address:str) -> str:
        """
        Builds the full address of the page to load, using the server address and cls.URL.
        Called from `cls._load_url`.
        `server_address` should end with a slash.
        """


    @classmethod
    def base_gather_data(cls) -> None:
        """
        Module level data handling.
        Called from `cls._load_url`.

        WARNING: any information/data that would be stored at this point must be "attached"
        to the current class directly (`SearchPage`, here), so that any subclass or instance
        of a subclass can actually access the data.
        """
        ...
        super.base_gather_data()
        ...

    @classmethod
    def gather_data(cls) -> None:
        """
        Module level data handling, as a second entry point to tweak the logic differently if needed.
        Called from `super.base_gather_data`.

        WARNING: any information/data that would be stored at this point must be "attached"
        to the current class directly (`SearchPage`, here), so that any subclass or instance
        of a subclass can actually access the data.
        """

    @classmethod
    def _build_data(cls):
        """
        Override cls._build_data() in subclasses to define data specific to THAT class (without
        reloading the page).
        Must be called from the `cls.data` fixture.
        """

Gathering shared page data

BaseSeleniumPage has two levels of data gathering:

base_gather_data()
        ↓
gather_data()

The default implementation is:

@classmethod
def base_gather_data(cls):
    cls.gather_data()

A subclass can override gather_data to build data shared across subclasses using the same page:

class SearchPage(BaseSeleniumPage):

    @classmethod
    def gather_data(cls):
        SearchPage.search_results = cls.find(".result").get.text

The source code deliberately emphasizes that data gathered at this level should be attached to the class extending BaseSeleniumPage itself when it needs to be accessible to all subclasses and instances.

This is important because:

different subclass
        ↓
different class object
        ↓
class-level data on the subclass may not be shared

Whereas:

SearchPage.some_shared_data

is accessible from every subclass.


Building subclass-specific data

_build_data is intended for data specific to one subclass:

@classmethod
def _build_data(cls):
    ...

A subclass can override it:

class SearchPage(BaseSeleniumPage):

    @classmethod
    def _build_data(cls):
        cls.search_input = cls.find("#search")
        cls.submit_button = cls.find("#submit")

This is different from shared data gathered at the base level:

gather_data()
    → data shared by the page hierarchy

_build_data()
    → data specific to this subclass/batch of tests

The exact fixture wiring depends on the test environment, but the conceptual separation is intentional.


Page readiness

After loading the URL, _load_url waits for the page to be ready:

cls.wait_until(page_is_ready or cls.page_is_ready)

The default readiness method is deliberately simple:

@classmethod
def page_is_ready(cls, driver):
    cls.pause(0.8)
    return True

A page can override the readiness condition when loading is asynchronous:

class SearchPage(BaseSeleniumPage):
    URL = "search"

    @classmethod
    def page_is_ready(cls, _driver):
        return cls.find("css selector, #search-ready").is_displayed()

The actual condition depends on the page being tested.

A more general mechanism is:

cls.wait_until(predicate)

where predicate must receive the WebDriver instance and return a bool.


find: the normal way to start a query

The most important user-facing method is:

@classmethod
def find(cls, css_selector, as_=None):
    return cls._getter.find(
        css_selector,
        in_page=True,
        as_=as_
    )

This is the normal entry point into Selenium Query:

class SearchPage(BaseSeleniumPage):
    URL = "search"

    @classmethod
    def search(cls, value):
        cls.find("#search").send_keys(value).go
        cls.find("#submit").go

The page owns the root Getter, and find(...) creates a page-scoped query.

This is why users should normally write:

MyPage.find(".item")

or:

self.find(".item")

rather than:

Getter(...)

BaseSeleniumPage.find is the bridge between the page object and the collection-oriented query API.


Waiting for elements

For a direct element wait, the page exposes:

@classmethod
def wait_for_element(cls, css_selector):
    return cls._getter.wait_for_element(css_selector)

Example:

class SearchPage(BaseSeleniumPage):

    @classmethod
    def wait_until_loaded(cls):
        cls.wait_for_element("#search")

For arbitrary conditions, use:

cls.wait_until(...)

For example:

class SearchPage(BaseSeleniumPage):

    @classmethod
    def wait_until_results_are_loaded(cls):
        cls.wait_until(
            lambda driver: len(
                driver.find_elements(
                    "css selector",
                    ".result"
                )
            ) > 0
        )

The difference is:

wait_for_element(...)
    → wait for a CSS-selected element

wait_until(...)
    → wait for any user-defined truthy condition

Handling JavaScript alerts

Some pages display an alert immediately when loaded.

This is controlled by:

WITH_ALERT = True

and:

alert_msg

For example:

class AlertPage(BaseSeleniumPage):
    URL = "alert-page"
    WITH_ALERT = True
    alert_msg: str = None

The dedicated readiness helper is:

page_with_alert_is_ready

Its behavior is:

  1. detect the alert;
  2. store its text in alert_msg;
  3. accept the alert;
  4. return False so the wait continues;
  5. once the alert has been handled, continue with the normal page readiness logic.

An alert can also be explicitly awaited from various places in the code of the tests with:

alert: Alert = AlertPage.wait_for_alert()

Pausing execution

The page exposes:

BaseSeleniumPage.pause(seconds)

which uses internally Selenium's ActionChains:

class MyPage(BaseSeleniumPage):

    @classmethod
    def wait_for_animation(cls):
        cls.pause(1.0)

The method returns the class itself, allowing a fluent style:

MyPage.pause(0.5)

This should generally be reserved for situations where a real timed pause is required. For normal synchronization, a condition-based wait is preferable:

MyPage.wait_until(...)

Executing JavaScript

run_js delegates JavaScript execution to the underlying query system:

BaseSeleniumPage.run_js(
    "return document.title"
)

Arguments are passed to JavaScript through Selenium's arguments array:

result = MyPage.run_js(
    "return arguments[0] + arguments[1]",
    1,
    2
)

Inside JavaScript:

arguments[0] // 1
arguments[1] // 2

The method also supports asynchronous execution:

MyPage.run_js(
    "return fetch('/api/data').then(response => response.json())",
    async_=True
)

When async_=True and with_promise=True, the library expects the JavaScript expression to be a Promise and automatically adds the necessary continuation to resume Selenium's asynchronous execution.

For custom asynchronous scripts, with_promise=False can be used when the script explicitly manages the Selenium callback: a call to done() must be present in the given JS code (done is an alias for the last argument provided by selenium, when executing async scripts).


Test execution tracking

BaseSeleniumPage also provides infrastructure for test dependencies.

When a subclass is created, its methods whose names start with:

test_

are automatically wrapped.

The wrapper records that the method has run:

_methods_run

A test can then declare a dependency:

@BaseSeleniumPage.depends_on(test_setup)
def test_feature(self):
    ...

If test_setup has not run yet, it is executed first.

Arguments can optionally be passed through:

@BaseSeleniumPage.depends_on(
    test_setup,
    pass_args=True
)
def test_feature(self, value):
    ...

The dependency mechanism is primarily intended to make dependent tests work correctly when individual tests are launched directly.

It is not intended as a general-purpose “run this method only once” mechanism.


A complete page skeleton

A page class can combine these mechanisms:

from selenium_query import BaseSeleniumPage


class SearchPage(BaseSeleniumPage):
    URL = "search"

    @classmethod
    def load_url(cls, clean_cache, selenium_env):
        cls._load_url(selenium_env)

    @classmethod
    def data(cls, load_url):
        cls._build_data()

    @classmethod
    def _build_data(cls):
        cls.search_input = cls.find("#search")
        cls.submit_button = cls.find("#submit")

    @classmethod
    def search(cls, value):
        cls.search_input.send_keys(value)
        cls.submit_button.go

    @classmethod
    def wait_for_results(cls):
        cls.wait_for_element(".result")

    @classmethod
    def result_titles(cls):
        return cls.find(".result").get.text

The page class owns the browser lifecycle and page-level synchronization, while Getter and its specialized query interfaces handle collection-oriented DOM operations.

For actual/concrete examples of use, you might refer to these:

Both make also use of the pmt-tools package, which is a second layer around selenium-query, building dedicated base objects to test:

  • IDEs, terminals, ...
  • Tabbed contents (see mkdocs-material === "tabbed" syntaxes)
  • ...
  • BasePmtPage, which is extending BaseSeleniumPage with functionalities dedicated to test websites generated with Pyodide-MkDocs-Theme.

In short

BaseSeleniumPage is the layer that connects the Selenium environment to Selenium Query:

Selenium environment
        ↓
  BaseSeleniumPage
        │
        ├── load URL
        ├── wait for readiness
        ├── gather data
        ├── manage alerts
        ├── execute JavaScript
        └── create queries
                ↓
            find(...)
                ↓
              Getter
                ↓
          DOM collections

For application code, the key entry point is:

page.find("css selector")

BaseSeleniumPage is therefore not just a convenience wrapper around WebDriver: it is the page-level abstraction that owns the browser context, synchronization, page loading, caching and the root from which Selenium Query collections are created.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

selenium_query-0.2.0.tar.gz (47.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

selenium_query-0.2.0-py3-none-any.whl (61.0 kB view details)

Uploaded Python 3

File details

Details for the file selenium_query-0.2.0.tar.gz.

File metadata

  • Download URL: selenium_query-0.2.0.tar.gz
  • Upload date:
  • Size: 47.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.1.3 CPython/3.10.10 Linux/6.8.0-136-generic

File hashes

Hashes for selenium_query-0.2.0.tar.gz
Algorithm Hash digest
SHA256 8e1d3fc6616cd525b7b77439d777bb8686c4c654966a1b6947bc62d09ec5f46f
MD5 877496f948e4abf82d1f839c14a4b749
BLAKE2b-256 62da086ef8ba44229a249d4d3021ada87d06376c73b62da5abf3780667b5bae7

See more details on using hashes here.

File details

Details for the file selenium_query-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: selenium_query-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 61.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.1.3 CPython/3.10.10 Linux/6.8.0-136-generic

File hashes

Hashes for selenium_query-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 80f838c23fe1d1aa8b56cfdf1be03a2ce5d5560a9f902bed145fe19b67a210a9
MD5 2e5efa10f044f0fe523b236509080cf2
BLAKE2b-256 feec992f5e329a5d3a5afe220a9e7faac7b0cfbf2d2f543a825241a95c35727e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page