Dec 6th, 2024: [EN] Adding OpenTelemetry to Your Flask Application

A to-do list can help manage all the shopping and tasks associated with planning for the holiday season. With Flask, you can easily create a to-do list application and monitor it with OpenTelemetry using Elastic as your telemetry backend.

Flask is a lightweight Python web framework that allows you to easily create applications. OpenTelemetry is an open-source, vendor-neutral observability framework that provides unified monitoring capabilities across different services and platforms, allowing seamless integration with various backend systems.

This post will walk you through using the Elastic Distribution of OpenTelemetry Python to monitor a to-do list application built in Flask. The full code for the examples outlined in this post can be found here.

How can you connect Elastic to OpenTelemetry?

One of the great things about OpenTelemetry is its flexibility to integrate with your applications. Use Elastic as a telemetry backend. You have a few options; you can use an OpenTelemetry collector, the official OpenTelmetry language client, to connect to APM, an AWS Lambda collector exporter. Our documentation lets you learn more about the options for connecting OpenTelemetry to Elastic.

You’ll use the Elastic Distribution of OpenTelemetry Python for the examples in this post. This library is a version of OpenTelemetry Python with additional features and support for integrating OpenTelemetry with Elastic. It is important to note that this package is currently in a preview and should not be used in a production environment.

Create your to-do list application with Flask

Before you can monitor your application, you must create it first. This post will guide you through creating a simple to-do list application to help you track tasks. When you complete your application, it will look like this:

Before starting, you will want to create a virtual environment. Once your virtual environment is created you will want to install the required packages.

pip install Flask Flask-SQLAlchemy

After installing the required packages, you must import the necessary packages and methods, configure your Flask application, and set up the SQLite database using SQLalachemy. After that, you will define a database model to store and list task items. You will also need to initialize your application to work with SQLalcamey and initialize the database by creating the needed tables.

from flask import Flask, request, render_template_string, redirect, url_for
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import Mapped, mapped_column

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tasks.db"
db = SQLAlchemy()

# Define a database model named Task for storing task data
class Task(db.Model):
   id: Mapped[int] = mapped_column(db.Integer, primary_key=True)
   description: Mapped[str] = mapped_column(db.String(256), nullable=False)

# Initialize SQLAlchemy with the configured Flask application
db.init_app(app)

# Initialize the database within the application context
with app.app_context():
   db.create_all()  # Creates all tables

You can now set up your HTML template to create the front end of your to-do list application, including inline CSS and form content for adding tasks. You will also define additional functionality for listing existing tasks and deleting tasks.

HOME_HTML = """
<!doctype html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>To-Do List</title>
 <style>
   body {
     font-family: Arial, sans-serif;
     background-color: #f4f4f9;
     margin: 40px auto;
     padding: 20px;
     max-width: 600px;
     box-shadow: 0 0 10px rgba(0,0,0,0.1);
   }
   h1 {
     color: #333;
   }
   form {
     margin-bottom: 20px;
   }
   input[type="text"] {
     padding: 10px;
     width: calc(100% - 22px);
     margin-bottom: 10px;
   }
   input[type="submit"] {
     background-color: #5cb85c;
     border: none;
     color: white;
     padding: 10px 20px;
     text-transform: uppercase;
     letter-spacing: 0.05em;
     cursor: pointer;
   }
   ul {
     list-style-type: none;
     padding: 0;
   }
   li {
     position: relative;
     padding: 8px;
     background-color: #fff;
     border-bottom: 1px solid #ddd;
   }
   .delete-button {
     position: absolute;
     right: 10px;
     top: 10px;
     background-color: #ff6347;
     color: white;
     border: none;
     padding: 5px 10px;
     border-radius: 5px;
     cursor: pointer;
   }
 </style>
</head>
<body>
 <h1>To-Do List</h1>
 <form action="/add" method="post">
   <input type="text" name="task" placeholder="Add new task">
   <input type="submit" value="Add Task">
 </form>
 <ul>
   {% for task in tasks %}
     <li>{{ task.description }} <button class="delete-button" onclick="location.href='/delete/{{ task.id }}'">Delete</button></li>
   {% endfor %}
 </ul>
</body>
</html>
"""

You can now create routes to show your to-do list tasks on your application upon loading, add new tasks, and delete tasks.

The / route allows you to define what data gets returned when someone visits the main page of your app; in this case, all the to-list tasks you’ve entered from a database will show on the screen.

For adding new tasks, when you fill out a form on your app to add and submit a new task, the /add route will save this new task in the database. After saving the task, it sends you back to the home page so they can see the list with the new task added.

You will also define a route for /delete, which describes what happens when you delete a task, in this case, when you click the delete button next to it. The app then removes that task from the database.

Finally, you will add logic to run your application.

# Define route for the home page to display tasks
@app.route("/", methods=["GET"])
def home():
   tasks = Task.query.all()  # Retrieve all tasks from the database
   return render_template_string(
       HOME_HTML, tasks=tasks
   )  # Render the homepage with tasks listed

# Define route to add new tasks from the form submission
@app.route("/add", methods=["POST"])
def add():
   task_description = request.form["task"]  # Extract task description from form data
   new_task = Task(description=task_description)  # Create new Task instance
   db.session.add(new_task)  # Add new task to database session
   db.session.commit()  # Commit changes to the database
   return redirect(url_for("home"))  # Redirect to the home page

# Define route to delete tasks based on task ID
@app.route("/delete/<int:task_id>", methods=["GET"])
def delete(task_id: int):
   task_to_delete = Task.query.get(task_id)  # Get task by ID
   if task_to_delete:
       db.session.delete(task_to_delete)  # Remove task from the database session
       db.session.commit()  # Commit the change to the database
   return redirect(url_for("home"))  # Redirect to the home page

# Check if the script is the main program and run the app
if __name__ == "__main__":
   app.run()  # Start the Flask application

To run your application locally, you can run the following in your terminal.

flask run -p 5000

Instrumenting your application

Instrumenting refers to adding observability features to your application to collect telemetry data, such as traces, metrics, and logs. In the data, you can see what dependent services you are running; for instance, you can see the application you are building, in this example, uses SQLite. You can also track a request as it moves through various services in a distributed system with spans and see quantitative information about processes running within a distributed system.

Automatic vs manual instrumentation

You have two options for instrumenting your application: automatic and manual instrumentation.

Automatic instrumentation modifies the bytecode of your application's classes to insert monitoring code into your application. With automatic instrumentation, you can easily monitor applications without worrying about creating custom monitoring. It can be a great way to start monitoring your application or add monitoring features to an existing application.

Manual instrumentation allows you to add custom code segments to your application to collect and transmit telemetry data. It is useful if you are looking for customization or find that the automatic instrumentation only covers some of what you need.

Adding automatic instrumentation to your to-do list application

To add automatic instrumentation to your Flask application, you don’t need to add any additional monitoring code. When you run your application, OpenTelemetry will automatically add the required code via the Python path. You can follow these steps to add automatic instrumentation to your application.

Step 1: Install the required packages

First, you will need to install the required packages for instrumentation.

pip install elastic-opentelemetry opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-flask
opentelemetry-bootstrap --action=install

Step 2: Setting local environment variables

Now, you can set your environment variables to include your application's service name, API key, and elastic host endpoint.

export OTEL_RESOURCE_ATTRIBUTES=service.name=todo-list-app
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey <your-api-key>"
export OTEL_EXPORTER_OTLP_ENDPOINT=https://<your-elastic-url>

Step 3: Running the application

To run your application with OpenTelemetry you will need to run the following in your terminal:

opentelemetry-instrument flask run -p 5000

At this point, if you look in Kibana, where it says "observability" followed by "services", you should see your service listed as the service name you set in your environment variables.

If you click on your service name, you should see a dashboard that contains observability data from your to-do list application. The screenshot below in the " Transactions " section shows actions you can take, such as loading all your to-do list items as you load your application with the GET method and adding a new item to your to-do list with the POST method.

Adding manual instrumentation to your to-do list application

Since manual instrumentation allows you to customize your instrumentation to your liking, you must add your own monitoring code to your application to get this up and running. The full code sample for this section can be found here.

First, you will need to update your service name by setting the following environment variable:

export OTEL_RESOURCE_ATTRIBUTES=service.name=mi-todo-list-app

Now that you have updated your service name, you want to update your import statements to include more features for monitoring your application, parsing your environment variables, and ensuring that your headers are appropriately formatted.

import os
from flask import Flask, request, render_template_string, redirect, url_for
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import Mapped, mapped_column
from opentelemetry import trace, metrics
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter

# Get environment variables
service_name = os.getenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=todo-flask-app").split("=")[-1]
otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
otlp_headers = os.getenv("OTEL_EXPORTER_OTLP_HEADERS")

if not otlp_endpoint or not otlp_headers:
   raise ValueError("OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS must be set in environment variables")

# Ensure headers are properly formatted for gRPC metadata
headers_dict = dict(item.split(":", 1) for item in otlp_headers.split(",") if ":" in item)

Now, you will want to configure your application to generate, batch, and send trace data to Elastic to provide insights into the operation and performance of your application.

# Configure tracing provider and exporter
resource = Resource(attributes={
    "service.name": service_name
})
trace.set_tracer_provider(TracerProvider(resource=resource))
tracer_provider = trace.get_tracer_provider()

otlp_trace_exporter = OTLPSpanExporter(
    endpoint=otlp_endpoint,
    headers=headers_dict,
)
span_processor = BatchSpanProcessor(otlp_trace_exporter)
tracer_provider.add_span_processor(span_processor)

You can now set up the framework for capturing telemetry data. You want to create a tracer, meter, and counter. A tracer creates spans for distributed tracing, helping to understand distributed systems' flow and performance issues. Your meter and counter captures operations metrics (like counting requests) are crucial for performance monitoring and alerting in production environments. Your metrics configuration ensures these metrics are appropriately batched and sent to Elastic for analysis.

# Create a tracer
tracer = trace.get_tracer(__name__)

# Configure metrics provider and exporter
otlp_metric_exporter = OTLPMetricExporter(
    endpoint=otlp_endpoint,
    headers=headers_dict,
)
metric_reader = PeriodicExportingMetricReader(otlp_metric_exporter)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)

# Create a meter
meter = metrics.get_meter(__name__)
requests_counter = meter.create_counter(
    name="requests_count",
    description="Number of requests received",
    unit="1",
)

You will want to instrument Flask and SQLite to get information about both services in your observability backend, which is Elastic.

FlaskInstrumentor().instrument_app(app)
with app.app_context():
    SQLAlchemyInstrumentor().instrument(engine=db.engine)

Now, you can equip each application route with tracing and metric collection. This is how you define which traces and metrics are added to each route (GET, POST, and DELETE), allowing you to gain visibility into the operational performance but also collecting valuable data on user interactions and system efficiency.

# Define route for the home page to display tasks
@app.route("/", methods=["GET"])
def home():
   with app.app_context():
       with tracer.start_as_current_span("home-request"):
           requests_counter.add(1, {"method": "GET", "endpoint": "/"})
           tasks = Task.query.all()  # Retrieve all tasks from the database
           return render_template_string(
               HOME_HTML, tasks=tasks
           )  # Render the homepage with tasks listed

# Define route to add new tasks from the form submission
@app.route("/add", methods=["POST"])
def add():
   with app.app_context():
       with tracer.start_as_current_span("add-task"):
           requests_counter.add(1, {"method": "POST", "endpoint": "/add"})
           task_description = request.form["task"]  # Extract task description from form data
           new_task = Task(description=task_description)  # Create new Task instance
           db.session.add(new_task)  # Add new task to database session
           db.session.commit()  # Commit changes to the database
           return redirect(url_for("home"))  # Redirect to the home page

# Define route to delete tasks based on task ID
@app.route("/delete/<int:task_id>", methods=["GET"])
def delete(task_id: int):
   with app.app_context():
       with tracer.start_as_current_span("delete-task"):
           requests_counter.add(1, {"method": "GET", "endpoint": f"/delete/{task_id}"})
           task_to_delete = Task.query.get(task_id)  # Get task by ID
           if task_to_delete:
               db.session.delete(task_to_delete)  # Remove task from the database session
               db.session.commit()  # Commit the change to the database
           return redirect(url_for("home"))  # Redirect to the home page

Since you have applied your custom monitoring code to your application, you can run in your terminal as you did when you first created it.

flask run -p 5000

You should now see your data under “Services” in the same way you did with automatic instrumentation.

Conclusion

Since one of the great features of OpenTelemetry is its customization, this is just the start of how you can utilize Elastic as an OpenTelemetry backend. As a next step, explore our OpenTelemetry demo application to see how Elastic can be used in a more realistic application. You also can deploy this application to a server as well.

The full code for this example can be found here. Let us know if you built anything based on this blog or if you have questions on our forums and the community Slack channel.