Skip to content

Commit c4600cb

Browse files
authored
Merge branch 'DataDog:main' into main
2 parents ca100fd + cd6be68 commit c4600cb

47 files changed

Lines changed: 777 additions & 297 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/stale.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Number of days of inactivity before an issue becomes stale
2+
daysUntilStale: 60
3+
# Number of days of inactivity before a stale issue is closed
4+
daysUntilClose: 7
5+
# Issues with these labels will never be considered stale
6+
exemptLabels:
7+
- pinned
8+
# Label to use when marking an issue as stale
9+
staleLabel: wontfix
10+
# Comment to post when marking an issue as stale. Set to `false` to disable
11+
markComment: >
12+
This issue has been automatically marked as stale and it will be closed
13+
if no further activity occurs. Thank you for your contributions! You can
14+
also find us in the \#serverless channel from the
15+
[Datadog community Slack](https://chat.datadoghq.com/).
16+
# Comment to post when closing a stale issue. Set to `false` to disable
17+
closeComment: false
18+

.github/workflows/build.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
- name: Set up Python
1414
uses: actions/setup-python@v2
1515
with:
16-
python-version: 3.7
16+
python-version: 3.9
1717

1818
- name: Install dependencies
1919
run: |
@@ -37,7 +37,7 @@ jobs:
3737
strategy:
3838
max-parallel: 4
3939
matrix:
40-
python-version: [2.7, 3.6, 3.7, 3.8]
40+
python-version: [2.7, 3.6, 3.7, 3.8, 3.9]
4141

4242
steps:
4343
- name: Checkout
@@ -64,7 +64,7 @@ jobs:
6464
runs-on: ubuntu-latest
6565
strategy:
6666
matrix:
67-
runtime-param: [2.7, 3.6, 3.7, 3.8]
67+
runtime-param: [2.7, 3.6, 3.7, 3.8, 3.9]
6868
steps:
6969
- name: Checkout
7070
uses: actions/checkout@v2

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Contributing
22

3-
We love pull requests. Here's a quick guide.
3+
We love pull requests. For new features, consider opening an issue to discuss the idea first. When you're ready to open a pull requset, here's a quick guide.
44

55
1. Fork, clone and branch off `main`:
66
```bash

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![Slack](https://chat.datadoghq.com/badge.svg?bg=632CA6)](https://chat.datadoghq.com/)
77
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/DataDog/datadog-lambda-python/blob/main/LICENSE)
88

9-
Datadog Lambda Library for Python (2.7, 3.6, 3.7 and 3.8) enables enhanced Lambda metrics, distributed tracing, and custom metric submission from AWS Lambda functions.
9+
Datadog Lambda Library for Python (2.7, 3.6, 3.7, 3.8, and 3.9) enables enhanced Lambda metrics, distributed tracing, and custom metric submission from AWS Lambda functions.
1010

1111
**IMPORTANT NOTE:** AWS Lambda is expected to receive a [breaking change](https://aws.amazon.com/blogs/compute/upcoming-changes-to-the-python-sdk-in-aws-lambda/) on **December 1, 2021**. If you are using Datadog Python Lambda layer version 7 or below, please upgrade to the latest.
1212

@@ -65,7 +65,7 @@ If `DD_FLUSH_TO_LOG` is set to `false` (not recommended), you must set `DD_SITE`
6565

6666
### DD_LOGS_INJECTION
6767

68-
Inject Datadog trace id into logs for [correlation](https://docs.datadoghq.com/tracing/connect_logs_and_traces/python/). Defaults to `true`.
68+
Inject Datadog trace id into logs for [correlation](https://docs.datadoghq.com/tracing/connect_logs_and_traces/python/) if you are using a `logging.Formatter` in the default `LambdaLoggerHandler` by the Lambda runtime. Defaults to `true`.
6969

7070
### DD_LOG_LEVEL
7171

datadog_lambda/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# The minor version corresponds to the Lambda layer version.
22
# E.g.,, version 0.5.0 gets packaged into layer version 5.
3-
__version__ = "3.42.0"
3+
__version__ = "3.46.0"
44

55

66
import os

datadog_lambda/api.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import os
2+
import logging
3+
import base64
4+
from datadog_lambda.extension import should_use_extension
5+
6+
logger = logging.getLogger(__name__)
7+
KMS_ENCRYPTION_CONTEXT_KEY = "LambdaFunctionName"
8+
9+
10+
def decrypt_kms_api_key(kms_client, ciphertext):
11+
from botocore.exceptions import ClientError
12+
13+
"""
14+
Decodes and deciphers the base64-encoded ciphertext given as a parameter using KMS.
15+
For this to work properly, the Lambda function must have the appropriate IAM permissions.
16+
17+
Args:
18+
kms_client: The KMS client to use for decryption
19+
ciphertext (string): The base64-encoded ciphertext to decrypt
20+
"""
21+
decoded_bytes = base64.b64decode(ciphertext)
22+
23+
"""
24+
When the API key is encrypted using the AWS console, the function name is added as an
25+
encryption context. When the API key is encrypted using the AWS CLI, no encryption context
26+
is added. We need to try decrypting the API key both with and without the encryption context.
27+
"""
28+
# Try without encryption context, in case API key was encrypted using the AWS CLI
29+
function_name = os.environ.get("AWS_LAMBDA_FUNCTION_NAME")
30+
try:
31+
plaintext = kms_client.decrypt(CiphertextBlob=decoded_bytes)[
32+
"Plaintext"
33+
].decode("utf-8")
34+
except ClientError:
35+
logger.debug(
36+
"Failed to decrypt ciphertext without encryption context, \
37+
retrying with encryption context"
38+
)
39+
# Try with encryption context, in case API key was encrypted using the AWS Console
40+
plaintext = kms_client.decrypt(
41+
CiphertextBlob=decoded_bytes,
42+
EncryptionContext={
43+
KMS_ENCRYPTION_CONTEXT_KEY: function_name,
44+
},
45+
)["Plaintext"].decode("utf-8")
46+
47+
return plaintext
48+
49+
50+
def init_api():
51+
if (
52+
not should_use_extension
53+
and not os.environ.get("DD_FLUSH_TO_LOG", "").lower() == "true"
54+
):
55+
# Make sure that this package would always be lazy-loaded/outside from the critical path
56+
# since underlying packages are quite heavy to load
57+
# and useless when the extension is present
58+
from datadog import api
59+
60+
if not api._api_key:
61+
import boto3
62+
63+
DD_API_KEY_SECRET_ARN = os.environ.get("DD_API_KEY_SECRET_ARN", "")
64+
DD_API_KEY_SSM_NAME = os.environ.get("DD_API_KEY_SSM_NAME", "")
65+
DD_KMS_API_KEY = os.environ.get("DD_KMS_API_KEY", "")
66+
DD_API_KEY = os.environ.get(
67+
"DD_API_KEY", os.environ.get("DATADOG_API_KEY", "")
68+
)
69+
70+
if DD_API_KEY_SECRET_ARN:
71+
api._api_key = boto3.client("secretsmanager").get_secret_value(
72+
SecretId=DD_API_KEY_SECRET_ARN
73+
)["SecretString"]
74+
elif DD_API_KEY_SSM_NAME:
75+
api._api_key = boto3.client("ssm").get_parameter(
76+
Name=DD_API_KEY_SSM_NAME, WithDecryption=True
77+
)["Parameter"]["Value"]
78+
elif DD_KMS_API_KEY:
79+
kms_client = boto3.client("kms")
80+
api._api_key = decrypt_kms_api_key(kms_client, DD_KMS_API_KEY)
81+
else:
82+
api._api_key = DD_API_KEY
83+
84+
logger.debug("Setting DATADOG_API_KEY of length %d", len(api._api_key))
85+
86+
# Set DATADOG_HOST, to send data to a non-default Datadog datacenter
87+
api._api_host = os.environ.get(
88+
"DATADOG_HOST", "https://api." + os.environ.get("DD_SITE", "datadoghq.com")
89+
)
90+
logger.debug("Setting DATADOG_HOST to %s", api._api_host)
91+
92+
# Unmute exceptions from datadog api client, so we can catch and handle them
93+
api._mute = False

datadog_lambda/dogstatsd.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import logging
2+
import os
3+
import socket
4+
import errno
5+
import re
6+
from threading import Lock
7+
8+
9+
MIN_SEND_BUFFER_SIZE = 32 * 1024
10+
log = logging.getLogger("datadog_lambda.dogstatsd")
11+
12+
13+
class DogStatsd(object):
14+
def __init__(self):
15+
self._socket_lock = Lock()
16+
self.socket_path = None
17+
self.host = "localhost"
18+
self.port = 8125
19+
self.socket = None
20+
self.encoding = "utf-8"
21+
22+
def get_socket(self, telemetry=False):
23+
"""
24+
Return a connected socket.
25+
26+
Note: connect the socket before assigning it to the class instance to
27+
avoid bad thread race conditions.
28+
"""
29+
with self._socket_lock:
30+
self.socket = self._get_udp_socket(
31+
self.host,
32+
self.port,
33+
)
34+
return self.socket
35+
36+
@classmethod
37+
def _ensure_min_send_buffer_size(cls, sock, min_size=MIN_SEND_BUFFER_SIZE):
38+
# Increase the receiving buffer size where needed (e.g. MacOS has 4k RX
39+
# buffers which is half of the max packet size that the client will send.
40+
if os.name == "posix":
41+
try:
42+
recv_buff_size = sock.getsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF)
43+
if recv_buff_size <= min_size:
44+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, min_size)
45+
log.debug("Socket send buffer increased to %dkb", min_size / 1024)
46+
finally:
47+
pass
48+
49+
@classmethod
50+
def _get_udp_socket(cls, host, port):
51+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
52+
sock.setblocking(0)
53+
cls._ensure_min_send_buffer_size(sock)
54+
sock.connect((host, port))
55+
56+
return sock
57+
58+
def distribution(self, metric, value, tags=None):
59+
"""
60+
Send a global distribution value, optionally setting tags.
61+
62+
>>> statsd.distribution("uploaded.file.size", 1445)
63+
>>> statsd.distribution("album.photo.count", 26, tags=["gender:female"])
64+
"""
65+
self._report(metric, "d", value, tags)
66+
67+
def close_socket(self):
68+
"""
69+
Closes connected socket if connected.
70+
"""
71+
with self._socket_lock:
72+
if self.socket:
73+
try:
74+
self.socket.close()
75+
except OSError as e:
76+
log.error("Unexpected error: %s", str(e))
77+
self.socket = None
78+
79+
def normalize_tags(self, tag_list):
80+
TAG_INVALID_CHARS_RE = re.compile(r"[^\w\d_\-:/\.]", re.UNICODE)
81+
TAG_INVALID_CHARS_SUBS = "_"
82+
return [
83+
re.sub(TAG_INVALID_CHARS_RE, TAG_INVALID_CHARS_SUBS, tag)
84+
for tag in tag_list
85+
]
86+
87+
def _serialize_metric(self, metric, metric_type, value, tags):
88+
# Create/format the metric packet
89+
return "%s:%s|%s%s" % (
90+
metric,
91+
value,
92+
metric_type,
93+
("|#" + ",".join(self.normalize_tags(tags))) if tags else "",
94+
)
95+
96+
def _report(self, metric, metric_type, value, tags):
97+
if value is None:
98+
return
99+
100+
payload = self._serialize_metric(metric, metric_type, value, tags)
101+
102+
# Send it
103+
self._send_to_server(payload)
104+
105+
def _send_to_server(self, packet):
106+
try:
107+
mysocket = self.socket or self.get_socket()
108+
mysocket.send(packet.encode(self.encoding))
109+
return True
110+
except socket.timeout:
111+
# dogstatsd is overflowing, drop the packets (mimicks the UDP behaviour)
112+
pass
113+
except (socket.herror, socket.gaierror) as socket_err:
114+
log.warning(
115+
"Error submitting packet: %s, dropping the packet and closing the socket",
116+
socket_err,
117+
)
118+
self.close_socket()
119+
except socket.error as socket_err:
120+
if socket_err.errno == errno.EAGAIN:
121+
log.debug(
122+
"Socket send would block: %s, dropping the packet", socket_err
123+
)
124+
elif socket_err.errno == errno.ENOBUFS:
125+
log.debug("Socket buffer full: %s, dropping the packet", socket_err)
126+
elif socket_err.errno == errno.EMSGSIZE:
127+
log.debug(
128+
"Packet size too big (size: %d): %s, dropping the packet",
129+
len(packet.encode(self.encoding)),
130+
socket_err,
131+
)
132+
else:
133+
log.warning(
134+
"Error submitting packet: %s, dropping the packet and closing the socket",
135+
socket_err,
136+
)
137+
self.close_socket()
138+
except Exception as e:
139+
log.error("Unexpected error: %s", str(e))
140+
return False
141+
142+
143+
statsd = DogStatsd()

datadog_lambda/extension.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import logging
2-
import requests
32
from os import path
43

4+
try:
5+
# only available in python 3
6+
# not an issue since the extension is not compatible with python 2.x runtime
7+
# https://docs.aws.amazon.com/lambda/latest/dg/using-extensions.html
8+
import urllib.request
9+
except ImportError:
10+
# safe since both calls to urllib are protected with try/expect and will return false
11+
urllib = None
12+
513
AGENT_URL = "http://127.0.0.1:8124"
614
HELLO_PATH = "/lambda/hello"
715
FLUSH_PATH = "/lambda/flush"
@@ -14,7 +22,7 @@ def is_extension_running():
1422
if not path.exists(EXTENSION_PATH):
1523
return False
1624
try:
17-
requests.get(AGENT_URL + HELLO_PATH)
25+
urllib.request.urlopen(AGENT_URL + HELLO_PATH)
1826
except Exception as e:
1927
logger.debug("Extension is not running, returned with error %s", e)
2028
return False
@@ -23,7 +31,8 @@ def is_extension_running():
2331

2432
def flush_extension():
2533
try:
26-
requests.post(AGENT_URL + FLUSH_PATH, data={})
34+
req = urllib.request.Request(AGENT_URL + FLUSH_PATH, "".encode("ascii"))
35+
urllib.request.urlopen(req)
2736
except Exception as e:
2837
logger.debug("Failed to flush extension, returned with error %s", e)
2938
return False

0 commit comments

Comments
 (0)