Skip to content

Commit

Permalink
fastapi instrumentation (#890)
Browse files Browse the repository at this point in the history
Co-authored-by: Leighton Chen <lechen@microsoft.com>
  • Loading branch information
toumorokoshi and lzchen committed Jul 15, 2020
1 parent 545068d commit 7bec76a
Show file tree
Hide file tree
Showing 13 changed files with 358 additions and 7 deletions.
1 change: 1 addition & 0 deletions docs-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ google-cloud-trace >=0.23.0
google-cloud-monitoring>=0.36.0
botocore~=1.0
starlette~=0.13
fastapi~=0.58.1
9 changes: 9 additions & 0 deletions docs/ext/fastapi/fastapi.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.. include:: ../../../ext/opentelemetry-instrumentation-fastapi/README.rst

API
---

.. automodule:: opentelemetry.instrumentation.fastapi
:members:
:undoc-members:
:show-inheritance:
5 changes: 5 additions & 0 deletions ext/opentelemetry-instrumentation-fastapi/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## Unreleased

- Initial release ([#890](https://github.com/open-telemetry/opentelemetry-python/pull/890))
43 changes: 43 additions & 0 deletions ext/opentelemetry-instrumentation-fastapi/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
OpenTelemetry FastAPI Instrumentation
=======================================

|pypi|

.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-fastapi.svg
:target: https://pypi.org/project/opentelemetry-instrumentation-fastapi/


This library provides automatic and manual instrumentation of FastAPI web frameworks,
instrumenting http requests served by applications utilizing the framework.

auto-instrumentation using the opentelemetry-instrumentation package is also supported.

Installation
------------

::

pip install opentelemetry-instrumentation-fastapi


Usage
-----

.. code-block:: python
import fastapi
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
app = fastapi.FastAPI()
@app.get("/foobar")
async def foobar():
return {"message": "hello world"}
FastAPIInstrumentor.instrument_app(app)
References
----------

* `OpenTelemetry Project <https://opentelemetry.io/>`_
55 changes: 55 additions & 0 deletions ext/opentelemetry-instrumentation-fastapi/setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
[metadata]
name = opentelemetry-instrumentation-fastapi
description = OpenTelemetry FastAPI Instrumentation
long_description = file: README.rst
long_description_content_type = text/x-rst
author = OpenTelemetry Authors
author_email = cncf-opentelemetry-contributors@lists.cncf.io
url = https://github.com/open-telemetry/opentelemetry-python/tree/master/ext/opentelemetry-instrumentation-fastapi
platforms = any
license = Apache-2.0
classifiers =
Development Status :: 4 - Beta
Intended Audience :: Developers
License :: OSI Approved :: Apache Software License
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8

[options]
python_requires = >=3.6
package_dir=
=src
packages=find_namespace:
install_requires =
opentelemetry-api == 0.11.dev0
opentelemetry-ext-asgi == 0.11.dev0

[options.entry_points]
opentelemetry_instrumentor =
fastapi = opentelemetry.instrumentation.fastapi:FastAPIInstrumentor

[options.extras_require]
test =
opentelemetry-test == 0.11.dev0
fastapi ~= 0.58.1
requests ~= 2.23.0 # needed for testclient

[options.packages.find]
where = src
31 changes: 31 additions & 0 deletions ext/opentelemetry-instrumentation-fastapi/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os

import setuptools

BASE_DIR = os.path.dirname(__file__)
VERSION_FILENAME = os.path.join(
BASE_DIR,
"src",
"opentelemetry",
"instrumentation",
"fastapi",
"version.py",
)
PACKAGE_INFO = {}
with open(VERSION_FILENAME) as f:
exec(f.read(), PACKAGE_INFO)

setuptools.setup(version=PACKAGE_INFO["__version__"])
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Optional

import fastapi
from starlette.routing import Match

from opentelemetry.ext.asgi import OpenTelemetryMiddleware
from opentelemetry.instrumentation.fastapi.version import __version__ # noqa
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor


class FastAPIInstrumentor(BaseInstrumentor):
"""An instrumentor for FastAPI
See `BaseInstrumentor`
"""

_original_fastapi = None

@staticmethod
def instrument_app(app: fastapi.FastAPI):
"""Instrument an uninstrumented FastAPI application.
"""
if not getattr(app, "is_instrumented_by_opentelemetry", False):
app.add_middleware(
OpenTelemetryMiddleware,
span_details_callback=_get_route_details,
)
app.is_instrumented_by_opentelemetry = True

def _instrument(self, **kwargs):
self._original_fastapi = fastapi.FastAPI
fastapi.FastAPI = _InstrumentedFastAPI

def _uninstrument(self, **kwargs):
fastapi.FastAPI = self._original_fastapi


class _InstrumentedFastAPI(fastapi.FastAPI):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_middleware(
OpenTelemetryMiddleware, span_details_callback=_get_route_details
)


def _get_route_details(scope):
"""Callback to retrieve the fastapi route being served.
TODO: there is currently no way to retrieve http.route from
a starlette application from scope.
See: https://github.com/encode/starlette/pull/804
"""
app = scope["app"]
route = None
for starlette_route in app.routes:
match, _ = starlette_route.matches(scope)
if match == Match.FULL:
route = starlette_route.path
break
if match == Match.PARTIAL:
route = starlette_route.path
# method only exists for http, if websocket
# leave it blank.
span_name = route or scope.get("method", "")
attributes = {}
if route:
attributes["http.route"] = route
return span_name, attributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

__version__ = "0.11.dev0"
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import unittest

import fastapi
from fastapi.testclient import TestClient

import opentelemetry.instrumentation.fastapi as otel_fastapi
from opentelemetry.test.test_base import TestBase


class TestFastAPIManualInstrumentation(TestBase):
def _create_app(self):
app = self._create_fastapi_app()
self._instrumentor.instrument_app(app)
return app

def setUp(self):
super().setUp()
self._instrumentor = otel_fastapi.FastAPIInstrumentor()
self._app = self._create_app()
self._client = TestClient(self._app)

def test_basic_fastapi_call(self):
self._client.get("/foobar")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 3)
for span in spans:
self.assertIn("/foobar", span.name)

def test_fastapi_route_attribute_added(self):
"""Ensure that fastapi routes are used as the span name."""
self._client.get("/user/123")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 3)
for span in spans:
self.assertIn("/user/{username}", span.name)
self.assertEqual(
spans[-1].attributes["http.route"], "/user/{username}"
)
# ensure that at least one attribute that is populated by
# the asgi instrumentation is successfully feeding though.
self.assertEqual(spans[-1].attributes["http.flavor"], "1.1")

@staticmethod
def _create_fastapi_app():
app = fastapi.FastAPI()

@app.get("/foobar")
async def _():
return {"message": "hello world"}

@app.get("/user/{username}")
async def _(username: str):
return {"message": username}

return app


class TestAutoInstrumentation(TestFastAPIManualInstrumentation):
"""Test the auto-instrumented variant
Extending the manual instrumentation as most test cases apply
to both.
"""

def _create_app(self):
# instrumentation is handled by the instrument call
self._instrumentor.instrument()
return self._create_fastapi_app()

def tearDown(self):
self._instrumentor.uninstrument()
super().tearDown()


class TestAutoInstrumentationLogic(unittest.TestCase):
def test_instrumentation(self):
"""Verify that instrumentation methods are instrumenting and
removing as expected.
"""
instrumentor = otel_fastapi.FastAPIInstrumentor()
original = fastapi.FastAPI
instrumentor.instrument()
try:
instrumented = fastapi.FastAPI
self.assertIsNot(original, instrumented)
finally:
instrumentor.uninstrument()

should_be_original = fastapi.FastAPI
self.assertIs(original, should_be_original)
2 changes: 1 addition & 1 deletion ext/opentelemetry-instrumentation-starlette/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ long_description = file: README.rst
long_description_content_type = text/x-rst
author = OpenTelemetry Authors
author_email = cncf-opentelemetry-contributors@lists.cncf.io
url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-instrumentation-starlette
url = https://github.com/open-telemetry/opentelemetry-python/tree/master/ext/opentelemetry-instrumentation-starlette
platforms = any
license = Apache-2.0
classifiers =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class StarletteInstrumentor(BaseInstrumentor):

@staticmethod
def instrument_app(app: applications.Starlette):
"""Instrument a previously instrumented Starlette application.
"""Instrument an uninstrumented Starlette application.
"""
if not getattr(app, "is_instrumented_by_opentelemetry", False):
app.add_middleware(
Expand Down

0 comments on commit 7bec76a

Please sign in to comment.