Bringing reliability closer to you with Reliably and DataDog
Tags: sre, reliability, reliably, devops
Published: 2021-07-23 10:14:04

As engineers we care about our users, at least we ought to :) They depend on us and our services to run just fine. This is reliability in a nutshell.

Site Reliability Engineering, or SRE if you're casual, has gained momentum to codify this view on reliability. This article is not about detailing SRE but focusing on how we can use one of its tools, Service Level Objectives (or SLO for short), to signal loss of reliability as close to engineers as we can.

Let's say we have a web application like this one below:

```python
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

async def homepage(request):
    return JSONResponse({'hello': 'world'})

app = Starlette(debug=True, routes=[
    Route('/', homepage),
])
```

Nothing fancy about it, just a *Hello World* example. When running it as follows:

```console
$ uvicorn --reload server:app
```

where `server` is the name of the Python module containing that code: server.py. The `--reload` flag allows us to change the code and let uvicorn restart the server automatically.

We can access this server as follows:

```bash
$ curl localhost:8000/
```

Now; let's run a basic load against this server using [hey](https://github.com/rakyll/hey):

```bash
$ hey -c 3 -q 10 -z 20s http://localhost:8000/

Summary:
  Total:	20.0125 secs
  Slowest:	0.0164 secs
  Fastest:	0.0020 secs
  Average:	0.0046 secs
  Requests/sec:	29.9813
  
  Total data:	10200 bytes
  Size/request:	17 bytes

Response time histogram:
  0.002 [1]	|
  0.003 [152]	|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.005 [184]	|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.006 [195]	|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.008 [53]	|■■■■■■■■■■■
  0.009 [11]	|■■
  0.011 [1]	|
  0.012 [1]	|
  0.014 [0]	|
  0.015 [0]	|
  0.016 [2]	|


Latency distribution:
  10% in 0.0027 secs
  25% in 0.0033 secs
  50% in 0.0043 secs
  75% in 0.0055 secs
  90% in 0.0067 secs
  95% in 0.0074 secs
  99% in 0.0083 secs

Details (average, fastest, slowest):
  DNS+dialup:	0.0000 secs, 0.0020 secs, 0.0164 secs
  DNS-lookup:	0.0000 secs, 0.0000 secs, 0.0005 secs
  req write:	0.0000 secs, 0.0000 secs, 0.0002 secs
  resp wait:	0.0043 secs, 0.0017 secs, 0.0161 secs
  resp read:	0.0001 secs, 0.0001 secs, 0.0007 secs

Status code distribution:
  [200]	600 responses
```

This will gently load our server without going overboard.

We likely want to monitor this server, why not use [DataDog](https://www.datadoghq.com/) to do so, as follows:

```python
from ddtrace import config, patch
import ddtrace.profiling.auto
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route


async def homepage(request):
    return JSONResponse({'hello': 'world'})


patch(starlette=True)
config.starlette['service_name'] = 'my-test-service'

app = Starlette(debug=True, routes=[
    Route('/', homepage),
])
```

What differs is that we are importing DataDog [ddtrace](https://docs.datadoghq.com/tracing/setup_overview/setup/python/) to push requests to the [local DataDog agent](https://docs.datadoghq.com/agent/docker/?tab=standard). The agent is started as follows on a different terminal:

```bash
$ export DD_API_KEY=...
$ export DD_SITE=datadoghq.eu

$ docker run --rm -it --name dd-agent \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /proc/:/host/proc/:ro \
-v /sys/fs/cgroup/:/host/sys/fs/cgroup:ro \
-e DD_API_KEY=${DD_API_KEY} \
-e DD_SITE=${DD_SITE} \
-e DD_APM_ENABLED=true \
-e DD_APM_NON_LOCAL_TRAFFIC=true \
-p 8126:8126/tcp \
gcr.io/datadoghq/agent:latest
```

After a couple of minutes, you'll be able to search for metrics from this application on DataDog. Look for metrics with `starlette` in the name.

Could we now trick the application into raising odd errors to fake a faulty service? Why yes of course! By simply returning a 4xx or 5xx class of errors at random from time to time:

```python
import random


from ddtrace import config, patch
import ddtrace.profiling.auto
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route


async def index(request: Request) -> JSONResponse:
    if random.random() > 0.91:
        return JSONResponse({'error': 'boom'}, status_code=500)
    return JSONResponse({'hello': 'world'})


patch(starlette=True)
config.starlette['distributed_tracing'] = True
config.starlette['service_name'] = 'my-frontend-service'

app = Starlette(debug=True, routes=[
    Route('/', index),
])
```

Let's see how this impacts our client now, run again our mild load:

```bash
$ hey -c 3 -q 10 -z 20s http://localhost:8000/

Summary:
  Total:	20.0120 secs
  Slowest:	0.0189 secs
  Fastest:	0.0018 secs
  Average:	0.0051 secs
  Requests/sec:	29.9820
  
  Total data:	10142 bytes
  Size/request:	16 bytes

Response time histogram:
  0.002 [1]	|
  0.004 [146]	|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.005 [193]	|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.007 [146]	|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.009 [101]	|■■■■■■■■■■■■■■■■■■■■■
  0.010 [10]	|■■
  0.012 [0]	|
  0.014 [0]	|
  0.016 [0]	|
  0.017 [1]	|
  0.019 [2]	|


Latency distribution:
  10% in 0.0029 secs
  25% in 0.0036 secs
  50% in 0.0050 secs
  75% in 0.0065 secs
  90% in 0.0074 secs
  95% in 0.0079 secs
  99% in 0.0092 secs

Details (average, fastest, slowest):
  DNS+dialup:	0.0000 secs, 0.0018 secs, 0.0189 secs
  DNS-lookup:	0.0000 secs, 0.0000 secs, 0.0005 secs
  req write:	0.0000 secs, 0.0000 secs, 0.0002 secs
  resp wait:	0.0048 secs, 0.0017 secs, 0.0187 secs
  resp read:	0.0002 secs, 0.0001 secs, 0.0011 secs

Status code distribution:
  [200]	542 responses
  [500]	58 responses
```

Now notice how we get a summary that does show us some responses were in errors as per our change above. Yai we broke something!

Can we now ask DataDog about these recorded errors? Yes we can:

```bash
# datadog info (change them to fit your owns)
export DD_API_KEY=
export DD_APP_KEY=
export DD_SITE=datadoghq.eu

# your query data
$ export query="(sum:trace.starlette.request.hits{service:my-test-service,resource_name:get_/}.as_count() - sum:trace.starlette.request.errors{service:my-test-service,resource_name:get_/}.as_count()) / (sum:trace.starlette.request.hits{service:my-test-service,resource_name:get_/}.as_count())"
$ export from=$(date "+%s" -d "15 min ago")
$ export to=$(date "+%s")

$ curl -G -s -X GET "https://api.${DD_SITE}/api/v1/query" \
--data-urlencode "from=${from}" \
--data-urlencode "to=${to}" \
--data-urlencode "query=${query}" \
-H "Content-Type: application/json" \
-H "DD-API-KEY: ${DD_API_KEY}" \
-H "DD-APPLICATION-KEY: ${DD_APP_KEY}" | jq .
```

The query we are running may look daunting but is rather straightforward. We take the total number of requests and we remove the ones that were on error. We then divide by the total again and this should give us a ratio of good requests as a percentage.

Great, we now have a query we can use to create a service level object (SLO) that will tell us how our service is doing over time. Let's use [Reliably](https://reliably.com) for this.

```bash
$ reliably slo init
? What is the name of the service you want to declare SLOs for? my-frontend-service
| Paste your 'numerator' (good events) datadog query: (sum:trace.starlette.request.hits{service:my-test-service,resource_name:get_/}.as_count() - sum:trace.starlette.request.errors{service:my-test-service,resource_name:get_/}.as_count())
| Paste your 'denominator' (total events) datadog query: (sum:trace.starlette.request.hits{service:my-test-service,resource_name:get_/}.as_count())
? What is your target for this SLO (in %)? 99
? What is your observation window for this SLO? custom
? Define your custom observation window PT5M

? What is the name of this SLO? 99% of frontend responses over last 5 minutes are 2xx
SLO '99% of frontend responses over last 5 minutes are 2xx' added to Service 'my-frontend-service'

? Do you want to add another SLO? No
Service 'my-frontend-service' added

? Do you want to add another Service? No

✓ Your manifest has been saved to ./reliably.yaml
```

In a nutshell, we created a file that contains the definition of the SLO:

```yaml
apiVersion: reliably.com/v1
kind: Objective
metadata:
  labels:
    name: 99% of requests  over last 5 minutes
    service: my-test-service
spec:
  indicatorSelector:
    datadog_denominator_query: sum:trace.starlette.request.hits{service:my-test-service,resource_name:get_/}.as_count()
    datadog_numerator_query: (sum:trace.starlette.request.hits{service:my-test-service,resource_name:get_/}.as_count() / sum:trace.starlette.request.errors{service:my-test-service,resource_name:get_/}.as_count())
  objectivePercent: 99
  window: 1h0m0s
```

Now we can make reliably know about it:

```bash
$ reliably slo sync
```

Finally, while the application is still running with some load injected into it, start fetching data from DataDog, using the query we saw earlier and let Reliably consolidate them over the window duration given in the objective:

```bash
$ reliably slo agent -i3
```

Open now a new terminal and run the following:

```bash
$ reliably slo report -w
```

This will show you the SLO report for your service as computed by Reliably.

{% asciinema 426882 %}


So what happened exactly? Well, let's zoom in on a section of the SLO:

```yaml
  indicatorSelector:
    datadog_denominator_query: sum:trace.starlette.request.hits{service:my-test-service,resource_name:get_/}.as_count()
    datadog_numerator_query: (sum:trace.starlette.request.hits{service:my-test-service,resource_name:get_/}.as_count() / sum:trace.starlette.request.errors{service:my-test-service,resource_name:get_/}.as_count())
```

The `indicatorSelector` property is how the magic happens. These are used for the following purposes:

* giving the `reliably slo agent` command the means to know what provider to use, here DataDog, and therefore how to fetch the required datapoints, here the two queries. These datapoints are stored under the name of *indicators* on Reliably
* declaring how these objective and the indicators are mapped together

That second point is key. Indicators themselves are not declared as entities (or objects) as *objectives* are. Instead they are merely a stream of values consumed by Reliably when sent by a client (`reliably slo agent` or via the API directly). Upon receiving an indicator, Reliably looks at its labels and match this to any `indicatorSelector` of any objectives (in the current organization). This tells us that objectives and indicators are loosly coupled. The fact the `reliably.yaml` manifest contains the selector doesn't define the indicator, only how to match indicators to objectives.

At this stage, you have a simple declaration of a service level object that relies on DataDog's data to compute it. Since the SLO is a just a file, you can now store it alongside your code base and use it as part of your CI/CD pipeline to automate decision about releasing. We'll see this in a future article using GitHub actions.

The code for this article can be found on [GitHub](https://github.com/Lawouach/reliably-and-datadog).