Compare commits

..

1 Commits

Author SHA1 Message Date
leo 85def1535e wip 2026-07-22 18:30:49 +02:00
4 changed files with 88 additions and 92 deletions
-6
View File
@@ -8,15 +8,9 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(summary) report exception type in failure analytics
## Fixed
- 🐛(transcription) fix silent bug in speaker assignment
- 🐛(summary) extend tasks auto retry logic
- 🐛(summary) properly detect when failure webhook should be sent
### Changed
+27 -40
View File
@@ -423,6 +423,31 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
return mimetype_from_content or "application/octet-stream"
def _get_s3_client(*, override_domain: bool = True):
"""Return an S3 client, honoring the AWS_S3_DOMAIN_REPLACE endpoint override.
AWS_S3_DOMAIN_REPLACE is used when the backend and frontend reach object
storage under different domains (this is the case in the docker compose stack
used in development: the frontend connects to the object storage on localhost
while the backend uses the object storage service name declared in the stack).
The domain name is used to compute the signature, so it can't be changed
dynamically by the frontend; we build a dedicated boto3 client pointed at that
endpoint. Otherwise we reuse the default storage client.
"""
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
return boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
return default_storage.connection.meta.client
def generate_upload_policy(file):
"""
Generate a S3 upload policy for a given file.
@@ -433,26 +458,7 @@ def generate_upload_policy(file):
key = file.temporary_file_key
# This settings should be used if the backend application and the frontend application
# can't connect to the object storage with the same domain. This is the case in the
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
s3_client = _get_s3_client()
# Generate the policy
policy = s3_client.generate_presigned_url(
@@ -473,26 +479,7 @@ def generate_download_s3_url(
if not key:
raise ValueError("key cannot be empty")
# This setting should be used if the backend application and the frontend application
# can't connect to the object storage with the same domain. This is the case in the
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
s3_client = _get_s3_client(override_domain=override_domain)
return s3_client.generate_presigned_url(
ClientMethod="get_object",
+1 -4
View File
@@ -202,16 +202,13 @@ class MetadataManager:
},
)
def capture(self, task_id, event_name, extra_properties=None):
def capture(self, task_id, event_name):
"""Capture analytics event with task metadata and clean up."""
if self._is_disabled or not self.has_task_id(task_id):
return
metadata = self._get_metadata(task_id)
if extra_properties:
metadata = {**metadata, **extra_properties}
if "start_time" in metadata:
metadata["execution_time"] = round(time.time() - metadata["start_time"], 2)
del metadata["start_time"]
+60 -42
View File
@@ -431,7 +431,7 @@ def _should_auto_create_summary(payload: TranscribeTaskJob) -> bool:
@celery.task(
max_retries=3,
queue=settings.call_webhook_queue_v2,
autoretry_for=[exceptions.RequestException],
autoretry_for=[exceptions.HTTPError],
)
def call_webhook_v2_task(
payload: dict,
@@ -445,9 +445,7 @@ def call_webhook_v2_task(
@celery.task(
bind=True,
autoretry_for=[
exceptions.RequestException,
],
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue_v2,
)
@@ -577,6 +575,12 @@ def task_retry_handler_transcript(request=None, reason=None, einfo=None, **kwarg
metadata_manager.retry(request.id)
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
def task_failure_handler_transcript(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_transcript_failure)
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
def handle_transcribe_v2_failed(
sender,
@@ -590,26 +594,30 @@ def handle_transcribe_v2_failed(
):
"""Handle the failure of transcribe_v2_task.
Tracks the failure event in analytics and sends a failure webhook to the client.
This function is triggered when the transcribe_v2_task fails.
It sends a webhook failure payload to notify the client of the failure.
"""
logger.error(
"Transcribe task %s failed, no more retries left, sending failure webhook.",
task_id,
)
metadata_manager.capture(
task_id,
settings.posthog_transcript_failure,
{"exception_type": type(exception).__name__},
)
call_webhook_v2_task.apply_async(
args=[
TranscribeWebhookFailurePayload(
job_id=task_id,
error_code="unknown_error",
).model_dump(),
args[0]["tenant_id"],
]
)
n_retries_left = sender.max_retries - sender.request.retries - 1
if n_retries_left > 0:
logger.info(
"Transcribe task %s failed, %s retries left.",
task_id,
n_retries_left,
)
else:
logger.warn(
"Transcribe task %s failed, no more retries left, sending failure webhook.",
task_id,
)
call_webhook_v2_task.apply_async(
args=[
TranscribeWebhookFailurePayload(
job_id=task_id,
error_code="unknown_error",
).model_dump(),
args[0]["tenant_id"],
]
)
@celery.task(
@@ -673,6 +681,12 @@ def task_retry_handler_summary(request=None, reason=None, einfo=None, **kwargs):
metadata_manager.retry(request.id)
@signals.task_failure.connect(sender=summarize_v2_task)
def task_failure_handler_summary(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_summary_failure)
@signals.task_failure.connect(sender=summarize_v2_task)
def handle_summarize_v2_failed(
sender,
@@ -686,23 +700,27 @@ def handle_summarize_v2_failed(
):
"""Handle the failure of summarize_v2_task.
Tracks the failure event in analytics and sends a failure webhook to the client.
This function is triggered when the summarize_v2_task fails.
It sends a webhook failure payload to notify the client of the failure.
"""
logger.warn(
"Summary task %s failed, no more retries left, sending failure webhook.",
task_id,
)
metadata_manager.capture(
task_id,
settings.posthog_summary_failure,
{"exception_type": type(exception).__name__},
)
call_webhook_v2_task.apply_async(
args=[
SummarizeWebhookFailurePayload(
job_id=task_id,
error_code="unknown_error",
).model_dump(),
args[0]["tenant_id"],
]
)
n_retries_left = sender.max_retries - sender.request.retries - 1
if n_retries_left > 0:
logger.info(
"Summary task %s failed, %s retries left.",
task_id,
n_retries_left,
)
else:
logger.warn(
"Summary task %s failed, no more retries left, sending failure webhook.",
task_id,
)
call_webhook_v2_task.apply_async(
args=[
SummarizeWebhookFailurePayload(
job_id=task_id,
error_code="unknown_error",
).model_dump(),
args[0]["tenant_id"],
]
)