diff --git a/ai_web_common/flask_ai/app.py b/ai_web_common/flask_ai/app.py
index 0f175378d0bb158288dce0addbd62d86ab95b77b..c92bac3cfd5fb48bf352ddad1c611316d8e57733 100644
--- a/ai_web_common/flask_ai/app.py
+++ b/ai_web_common/flask_ai/app.py
@@ -2,6 +2,7 @@ import hashlib
 import pkg_resources
 from flask import request, session, g
 from flask_talisman import DENY
+from opentelemetry.instrumentation.flask import FlaskInstrumentor
 from .tracing import setup_tracing
 from whitenoise import WhiteNoise
 
@@ -14,6 +15,9 @@ DEFAULT_LANGUAGES = [
 ]
 
 
+_telemetry_instrumentor = FlaskInstrumentor()
+
+
 def init_app(app, talisman):
     """Initialize the Flask application."""
 
@@ -44,7 +48,9 @@ def init_app(app, talisman):
     app.config['SUPPORTED_LANGUAGES_ISO'] = [
         x[0] for x in app.config['SUPPORTED_LANGUAGES']]
 
-    setup_tracing(app, app.import_name)
+    _telemetry_instrumentor.instrument_app(app)
+    if setup_tracing(app.import_name):
+        app.logger.info('configured request tracing')
 
     # Autodetect language as best as we can, by retrieving it in order
     # from either:
diff --git a/ai_web_common/flask_ai/test/__init__.py b/ai_web_common/flask_ai/test/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..db462ff99bf490a2b95e9eb1f47dc964facb970c
--- /dev/null
+++ b/ai_web_common/flask_ai/test/__init__.py
@@ -0,0 +1 @@
+from flask_testing import TestCase
diff --git a/ai_web_common/flask_ai/test/test_simple_app.py b/ai_web_common/flask_ai/test/test_simple_app.py
new file mode 100644
index 0000000000000000000000000000000000000000..c984e76e5d2b50d768bbc835c769bda31c5f82ea
--- /dev/null
+++ b/ai_web_common/flask_ai/test/test_simple_app.py
@@ -0,0 +1,29 @@
+from ai_web_common.flask_ai.app import init_app
+from ai_web_common.flask_ai.test import TestCase
+from flask import Flask, make_response
+from flask_talisman import Talisman
+
+
+app = Flask(__name__)
+talisman = Talisman()
+
+
+@app.route('/')
+def index():
+    return make_response('ok')
+
+
+class TestSimpleApp(TestCase):
+
+    def create_app(self):
+        app.config.update({
+            'TESTING': True,
+            'DEBUG': False, 
+        })
+        init_app(app, talisman)
+        return app
+
+    def test_request(self):
+        r = self.client.get('/')
+        self.assertEquals(200, r.status_code)
+        self.assertEquals('ok', r.data.decode('utf-8'))
diff --git a/ai_web_common/flask_ai/tracing.py b/ai_web_common/flask_ai/tracing.py
index 693b7a0b10882fb682cbee517566f55adeb9d328..4af9a647b179573746ac898d2ddf3131719ceb9b 100644
--- a/ai_web_common/flask_ai/tracing.py
+++ b/ai_web_common/flask_ai/tracing.py
@@ -1,26 +1,33 @@
 import json
 import os
-from urllib.parse import urlsplit
-from opencensus.trace import config_integration
+
+from opentelemetry import trace
+from opentelemetry.exporter.zipkin.proto.http import ZipkinExporter
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+from opentelemetry.sdk.resources import SERVICE_NAME, Resource
+
 
 tracing_config_file = os.getenv('TRACING_CONFIG', '/etc/tracing/client.conf')
 
 
-def setup_tracing(app, service_name):
+def setup_tracing(service_name):
     try:
         with open(tracing_config_file) as fd:
             config = json.load(fd)
     except (OSError, IOError):
-        return
+        return False
     if 'report_url' not in config:
-        return
+        return False
+
+    resource = Resource(attributes={
+        SERVICE_NAME: service_name,
+    })
 
-    # Patch the 'requests' module so that HTTP clients propagate the
-    # trace ID. Note that this is mostly useless, as the requests
-    # integration only patches the high-level API (get, post) and does
-    # not touch the low-level Session interface that we're using for
-    # our own RPC calls.
-    config_integration.trace_integrations(['requests'])
+    zipkin_exporter = ZipkinExporter(endpoint=config['report_url'])
 
-    app.logger.info('configured request tracing with service=%s',
-                    service_name)
+    provider = TracerProvider(resource=resource)
+    processor = BatchSpanProcessor(zipkin_exporter)
+    provider.add_span_processor(processor)
+    trace.set_tracer_provider(provider)
+    return True
diff --git a/ai_web_common/rpc/core.py b/ai_web_common/rpc/core.py
index 9d8b455c5eec712b356ac91df40ff920316b9669..ba1bb3628b32c381dd082a2151fc2a412a09d866 100644
--- a/ai_web_common/rpc/core.py
+++ b/ai_web_common/rpc/core.py
@@ -8,9 +8,11 @@ import threading
 import time
 import urllib3
 import urllib3.util
-from opencensus.trace import attributes_helper
-from opencensus.trace import execution_context
-from opencensus.trace import span as span_module
+
+from opentelemetry import trace
+from opentelemetry.trace.status import Status
+from opentelemetry.semconv.trace import SpanAttributes
+from opentelemetry.instrumentation.utils import http_status_to_status_code
 
 
 DNS_CACHE_TTL = 60
@@ -20,11 +22,6 @@ DEFAULT_MAX_TIMEOUT = 30
 DEFAULT_MAX_BACKOFF_INTERVAL = 3
 
 
-HTTP_URL = attributes_helper.COMMON_ATTRIBUTES['HTTP_URL']
-HTTP_HOST = attributes_helper.COMMON_ATTRIBUTES['HTTP_HOST']
-HTTP_STATUS_CODE = attributes_helper.COMMON_ATTRIBUTES['HTTP_STATUS_CODE']
-
-
 class StatusError(Exception):
 
     def __init__(self, url, resp, extra_msg=None):
@@ -229,11 +226,16 @@ class ClientStub():
 
         target_addr, target_port = targets.next()
 
-        tracer = execution_context.get_opencensus_tracer()
-        with tracer.span(name=f'[client-rpc] {self.NAME}') as span:
-            span.span_kind = span_module.SpanKind.CLIENT
-            tracer.add_attribute_to_current_span(HTTP_URL, parsed_url.url)
-            tracer.add_attribute_to_current_span(HTTP_HOST, target_addr)
+        tracer = trace.get_tracer(self.NAME)
+        with tracer.start_as_current_span(
+                f'[client-rpc] {self.NAME}',
+                kind=trace.SpanKind.CLIENT,
+                attributes={
+                    SpanAttributes.HTTP_URL: parsed_url.url,
+                    SpanAttributes.HTTP_HOST: target_addr,
+                    SpanAttributes.HTTP_METHOD: 'POST',
+                },
+        ) as span:
 
             pool_args = {
                 'cls': urllib3.HTTPConnectionPool,
@@ -279,8 +281,8 @@ class ClientStub():
                 retries=False,
             )
             try:
-                tracer.add_attribute_to_current_span(
-                    HTTP_STATUS_CODE, str(resp.status))
+                span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, resp.status)
+                span.set_status(Status(http_status_to_status_code(resp.status)))
 
                 if resp.status == 429 or resp.status > 500:
                     raise RetriableStatusError(parsed_url, resp)
diff --git a/setup.py b/setup.py
index 1f9ab2a31b1c0f66aab467ddba3d57af77ceb946..07e457a516adb7f9cf2516e53dd6090b7913b71b 100755
--- a/setup.py
+++ b/setup.py
@@ -14,9 +14,9 @@ setup(
         "backoff",
         "Flask",
         "flask-talisman",
-        "opencensus",
-        "opencensus-ext-zipkin",
-        "opencensus-ext-requests",
+        "opentelemetry-distro",
+        "opentelemetry-exporter-zipkin-proto-http",
+        "opentelemetry-instrumentation-flask",
         "sso",
         "urllib3",
         "whitenoise",
diff --git a/tox.ini b/tox.ini
index 2f7f2920484cc3fe698aaaeacb04d6922fed6357..d01873c4a5acca69d329843160606112d7777288 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,6 +5,7 @@ envlist = py3
 deps=
   git+https://git.autistici.org/ai/sso.git#egg=sso&subdirectory=src/python
   cryptography
+  Flask-Testing
   coverage
   nose
   mock