Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extension to instrument Azure Functions #1766

Merged
merged 7 commits into from Mar 21, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.asciidoc
Expand Up @@ -33,8 +33,9 @@ endif::[]

// Unreleased changes go here
// When the next release happens, nest these changes under the "Python Agent version 6.x" heading
//[float]
//===== Features
[float]
===== Features
* Implement instrumentation of Azure Functions {pull}1766[#1766]

[float]
===== Bug fixes
Expand Down
62 changes: 62 additions & 0 deletions docs/serverless-azure-functions.asciidoc
@@ -0,0 +1,62 @@
[[azure-functions-support]]
=== Monitoring Azure Functions

Incorporating Elastic APM into your Azure Functions App is straight forward!
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm leaning towards deleting this line. I'm not sure it adds anything useful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, true, it got cargo-culted from one docs page to the next. Should probably remove all of them.


[float]
==== Prerequisites

You need an APM Server to send APM data to.
beniwohli marked this conversation as resolved.
Show resolved Hide resolved
Follow the {apm-guide-ref}/apm-quick-start.html[APM Quick start] if you have not set one up yet.
For the best-possible performance, we recommend setting up APM on {ecloud} in the same Azure region as your Azure Functions app.

[float]
==== Step 1: Enable Worker Extensions

Elastic APM uses https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python?tabs=asgi%2Capplication-level&pivots=python-mode-configuration#python-worker-extensions[Worker Extensions]
to instrument Azure Functions.
This feature has to be enabled in your Azure Functions App.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
This feature has to be enabled in your Azure Functions App.
This feature is not enabled by default, and must be enabled in your Azure Functions App.

Please follow the instructions in the https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python?tabs=asgi%2Capplication-level&pivots=python-mode-configuration#using-extensions[Azure docs].

Once you have enabled Worker Extensions, these two lines of code will enable Elastic APM's extension:

[source,python]
----
from elasticapm.contrib.serverless.azure import ElasticAPMExtension

ElasticAPMExtension.configure()
----

Put them somewhere at the top of your Python file, before the function definitions.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How critical is their placement? Do they need to be before any init code or just before the function defs?


[float]
==== Step 2: Install the APM Python Agent

You need to add `elastic-apm` as a dependency for your Functions app.
Simply add `elastic-apm` to your `requirements.txt` file.
We recommend to pin the version to the current newest version of the agent, and periodically update it.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
We recommend to pin the version to the current newest version of the agent, and periodically update it.
We recommend pinning the version to the current newest version of the agent, and periodically updating the version.


[float]
==== Step 3: Configure APM on Azure Functions

The APM Python agent is configured through https://learn.microsoft.com/en-us/azure/azure-functions/functions-how-to-use-azure-function-app-settings?tabs=portal#settings[App Settings].
These are then picked up by the agent as environment variables.

For the minimal configuration, you will need the _APM Server URL_ to set the destination for APM data and an _{apm-guide-ref}/secret-token.html[APM Secret Token]_.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we link to our configuration docs for these?

If you prefer to use an {apm-guide-ref}/api-key.html[APM API key] instead of the APM secret token, use the `ELASTIC_APM_API_KEY` environment variable instead of `ELASTIC_APM_SECRET_TOKEN` in the following example configuration.

[source,bash]
----
$ az functionapp config appsettings set --settings ELASTIC_APM_SERVER_URL=https://example.apm.northeurope.azure.elastic-cloud.com:443
$ az functionapp config appsettings set --settings ELASTIC_APM_SECRET_TOKEN=verysecurerandomstring
----

You can optionally <<configuration,fine-tune the Python agent>>.

That's it; Once the agent is installed and working, spans will be captured for
<<supported-technologies,supported technologies>>. You can also use
<<api-capture-span,`capture_span`>> to capture custom spans, and
you can retrieve the `Client` object for capturing exceptions/messages
using <<api-get-client,`get_client`>>.

NOTE: Currently, only HTTP and timer triggers are supported.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this should be at the top? I don't know how much Azure function usage falls outside of these triggers but it seems like an important note.

2 changes: 2 additions & 0 deletions docs/set-up.asciidoc
Expand Up @@ -28,4 +28,6 @@ include::./sanic.asciidoc[]

include::./serverless.asciidoc[]

include::./serverless-azure-functions.asciidoc[]

include::./wrapper.asciidoc[]
218 changes: 218 additions & 0 deletions elasticapm/contrib/serverless/azure.py
@@ -0,0 +1,218 @@
# BSD 3-Clause License
#
# Copyright (c) 2023, Elasticsearch BV
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# * Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import codecs
import json
import os
import threading
from http.cookies import SimpleCookie
from typing import Dict, Optional, TypeVar

import azure.functions as func
from azure.functions.extension import AppExtensionBase

import elasticapm
from elasticapm.base import Client
from elasticapm.conf import constants
from elasticapm.utils import get_url_dict
from elasticapm.utils.disttracing import TraceParent
from elasticapm.utils.logging import get_logger

SERVERLESS_HTTP_REQUEST = ("api", "elb")

logger = get_logger("elasticapm.serverless")

_AnnotatedFunctionT = TypeVar("_AnnotatedFunctionT")

_cold_start_lock = threading.Lock()
COLD_START = True


class AzureFunctionsClient(Client):
def get_service_info(self):
service_info = super().get_service_info()
service_info["framework"] = {
"name": "Azure Functions",
"version": os.environ.get("FUNCTIONS_EXTENSION_VERSION"),
}
service_info["runtime"] = {
"name": os.environ.get("FUNCTIONS_WORKER_RUNTIME"),
"version": os.environ.get("FUNCTIONS_WORKER_RUNTIME_VERSION"),
}
service_info["node"] = {"configured_name": os.environ.get("WEBSITE_INSTANCE_ID")}
return service_info

def get_cloud_info(self):
cloud_info = super().get_cloud_info()
cloud_info.update(
{"provider": "azure", "region": os.environ.get("REGION_NAME"), "service": {"name": "functions"}}
)
account_id = get_account_id()
if account_id:
cloud_info["account"] = {"id": account_id}
if "WEBSITE_SITE_NAME" in os.environ:
cloud_info["instance"] = {"name": os.environ["WEBSITE_SITE_NAME"]}
if "WEBSITE_RESOURCE_GROUP" in os.environ:
cloud_info["project"] = {"name": os.environ["WEBSITE_RESOURCE_GROUP"]}
return cloud_info


class ElasticAPMExtension(AppExtensionBase):
functions = {}
client = None

@classmethod
def init(cls):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be gating this to obey enabled and instrument configs? I'm assuming this is getting called in the AppExtensionBase somewhere, perhaps we should put in a docstring noting where/how this is called?

elasticapm.instrument()

@classmethod
def configure(cls, client_class=AzureFunctionsClient, **kwargs):
client = elasticapm.get_client()
if not client:
kwargs["metrics_interval"] = "0ms"
kwargs["breakdown_metrics"] = "false"
if "metric_sets" not in kwargs and "ELASTIC_APM_METRICS_SETS" not in os.environ:
# Allow users to override metrics sets
kwargs["metrics_sets"] = []
kwargs["central_config"] = "false"
kwargs["cloud_provider"] = "none"
kwargs["framework_name"] = "Azure Functions"
if (
"service_name" not in kwargs
and "ELASTIC_APM_SERVICE_NAME" not in os.environ
and "WEBSITE_SITE_NAME" in os.environ
):
kwargs["service_name"] = os.environ["WEBSITE_SITE_NAME"]
if (
"environment" not in kwargs
and "ELASTIC_APM_ENVIRONMENT" not in os.environ
and "AZURE_FUNCTIONS_ENVIRONMENT" in os.environ
):
kwargs["environment"] = os.environ["AZURE_FUNCTIONS_ENVIRONMENT"]
client = AzureFunctionsClient(**kwargs)
cls.client = client

@classmethod
def post_function_load_app_level(cls, function_name: str, function_directory: str, *args, **kwargs):
with codecs.open(os.path.join(function_directory, "function.json")) as f:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason you're using codecs.open instead of just open? My understanding is that codecs.open is obsolete in py3.x but I could have missed something.

cls.functions[function_name] = json.load(f)

@classmethod
def pre_invocation_app_level(cls, logger, context, func_args: Dict[str, object] = None, *args, **kwargs):
client = cls.client
if not client:
return
global COLD_START
with _cold_start_lock:
cold_start, COLD_START = COLD_START, False
tp: Optional[TraceParent] = None
transaction_type = "request"
http_request_data = None
trigger_type = None
if func_args:
for arg in func_args.values():
if isinstance(arg, func.HttpRequest):
tp = TraceParent.from_headers(arg.headers) if arg.headers else None
transaction_type = "request"
http_request_data = lambda: get_data_from_request(
arg, client.config.capture_headers, client.config.capture_body in ("transactions", "all")
)
trigger_type = "request"
break
if isinstance(arg, func.TimerRequest):
transaction_type = "timer"
trigger_type = "timer"
break

cls.client.begin_transaction(transaction_type, trace_parent=tp)
if http_request_data:
elasticapm.set_context(http_request_data, "request")
elasticapm.set_context(lambda: get_faas_data(context, cold_start, trigger_type), "faas")

@classmethod
def post_invocation_app_level(
cls, logger, context, func_args: Dict[str, object] = None, func_ret=Optional[object], *args, **kwargs
):
client = cls.client
if isinstance(func_ret, func.HttpResponse):
elasticapm.set_context(lambda: get_data_from_response(func_ret, client.config.capture_headers), "response")
elasticapm.set_transaction_outcome(http_status_code=func_ret.status_code)
elasticapm.set_transaction_result(f"HTTP {func_ret.status_code // 100}xx")
client.end_transaction(context.function_name)


def get_data_from_request(request: func.HttpRequest, capture_headers=False, capture_body=False):
result = {
"method": request.method,
}
if "Cookie" in request.headers:
cookie = SimpleCookie()
cookie.load(request.headers["Cookie"])
result["cookies"] = {k: v.value for k, v in cookie.items()}
if capture_headers and request.headers:
result["headers"] = dict(request.headers)
if capture_body and request.method in constants.HTTP_WITH_BODY:
result["body"] = request.get_body()
result["url"] = get_url_dict(request.url)
return result


def get_data_from_response(response: func.HttpResponse, capture_headers: bool = False):
result = {
"status_code": response.status_code,
}
if capture_headers:
result["headers"] = dict(response.headers)
return result


def get_faas_data(context: func.Context, cold_start: bool, trigger_type: Optional[str]) -> dict:
account_id = get_account_id()
resource_group = os.environ.get("WEBSITE_RESOURCE_GROUP", None)
app_name = os.environ.get("WEBSITE_SITE_NAME", None)
function_name = context.function_name
data = {
"coldstart": cold_start,
"execution": context.invocation_id,
}
if trigger_type:
data["trigger"] = {"type": trigger_type}
if account_id and resource_group and app_name and function_name:
data["id"] = (
f"/subscriptions/{account_id}/resourceGroups/{resource_group}/providers/Microsoft.Web/sites/{app_name}/"
+ f"functions/{function_name}"
)
if app_name and function_name:
data["name"] = f"{app_name}/{function_name}"
return data


def get_account_id() -> Optional[str]:
return os.environ["WEBSITE_OWNER_NAME"].split("+", 1)[0] if "WEBSITE_OWNER_NAME" in os.environ else None
2 changes: 1 addition & 1 deletion elasticapm/traces.py
Expand Up @@ -452,7 +452,7 @@ def is_sampled(self) -> bool:
return self._is_sampled

@is_sampled.setter
def is_sampled(self, is_sampled):
def is_sampled(self, is_sampled: bool):
"""
This should never be called in normal operation, but often is used
for testing. We just want to make sure our sample_rate comes out correctly
Expand Down
29 changes: 29 additions & 0 deletions tests/contrib/serverless/azurefunctions/__init__.py
@@ -0,0 +1,29 @@
# BSD 3-Clause License
#
# Copyright (c) 2023, Elasticsearch BV
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# * Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.