Skip to content

Commit 7896615

Browse files
committed
Telemetry Schema 2 for Playbook Exp/Gen
Push events when: - we preare an explanation for a playbook - or we generate a new one Also: - rename `PlaybookOutlineFeedback` as `PlaybookGenerationFeedback` and accept the `wizardId` parameter. - rename `ContentMatches._write_to_segment()` to `ContentMatches.write_to_segment()` to match the other class of the module.
1 parent 9daa393 commit 7896615

File tree

9 files changed

+347
-170
lines changed

9 files changed

+347
-170
lines changed

ansible_wisdom/ai/api/serializers.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -265,27 +265,21 @@ class IssueFeedback(serializers.Serializer):
265265
)
266266

267267

268-
class PlaybookOutlineFeedback(serializers.Serializer):
268+
class PlaybookGenerationFeedback(serializers.Serializer):
269269
USER_ACTION_CHOICES = (('0', 'ACCEPTED'), ('1', 'REJECTED'), ('2', 'IGNORED'))
270270

271-
class Meta:
272-
fields = ['action', 'outlineId']
273-
274271
action = serializers.ChoiceField(choices=USER_ACTION_CHOICES, required=True)
275-
outlineId = serializers.UUIDField(
272+
wizardId = serializers.UUIDField(
276273
format='hex_verbose',
277274
required=True,
278275
label="Outline ID",
279-
help_text="A UUID that identifies the playbook outline.",
276+
help_text="A UUID that identifies the UI session.",
280277
)
281278

282279

283280
class PlaybookExplanationFeedback(serializers.Serializer):
284281
USER_ACTION_CHOICES = (('0', 'ACCEPTED'), ('1', 'REJECTED'), ('2', 'IGNORED'))
285282

286-
class Meta:
287-
fields = ['action', 'explanationId']
288-
289283
action = serializers.ChoiceField(choices=USER_ACTION_CHOICES, required=True)
290284
explanationId = serializers.UUIDField(
291285
format='hex_verbose',
@@ -324,7 +318,7 @@ class FeedbackRequestSerializer(Metadata):
324318
metadata = Metadata(required=False)
325319
model = serializers.CharField(required=False)
326320
playbookExplanationFeedback = PlaybookExplanationFeedback(required=False)
327-
playbookOutlineFeedback = PlaybookOutlineFeedback(required=False)
321+
playbookGenerationFeedback = PlaybookGenerationFeedback(required=False)
328322
sentimentFeedback = SentimentFeedback(required=False)
329323
suggestionQualityFeedback = SuggestionQualityFeedback(required=False)
330324

ansible_wisdom/ai/api/tests/test_views.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2544,6 +2544,7 @@ def test_wca_contentmatch_segment_events_with_key_error(self, mock_send_segment_
25442544

25452545
@override_settings(ANSIBLE_AI_ENABLE_TECH_PREVIEW=True)
25462546
@override_settings(ANSIBLE_AI_MODEL_MESH_API_TYPE="dummy")
2547+
@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
25472548
class TestExplanationView(WisdomAppsBackendMocking, WisdomServiceAPITestCaseBase):
25482549
response_data = """# Information
25492550
This playbook installs the Nginx web server on all hosts
@@ -2570,7 +2571,10 @@ def test_ok(self):
25702571
"ansibleExtensionVersion": "24.4.0",
25712572
}
25722573
self.client.force_authenticate(user=self.user)
2573-
r = self.client.post(reverse('explanations'), payload, format='json')
2574+
with self.assertLogs(logger='root', level='DEBUG') as log:
2575+
r = self.client.post(reverse('explanations'), payload, format='json')
2576+
segment_events = self.extractSegmentEventsFromLog(log)
2577+
self.assertEqual(segment_events[0]["properties"]["playbook_length"], 165)
25742578
self.assertEqual(r.status_code, HTTPStatus.OK)
25752579
self.assertIsNotNone(r.data["content"])
25762580
self.assertEqual(r.data["format"], "markdown")

ansible_wisdom/ai/api/utils/seated_users_allow_list.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,4 +298,45 @@
298298
'rh_user_org_id': None,
299299
'timestamp': None,
300300
},
301+
'explanation': {
302+
'duration': None,
303+
'exception': {'error_code': None, 'exception_class': None, 'message': None},
304+
'hostname': None,
305+
'imageTags': None,
306+
'path': None,
307+
'request': {'explanationId': None},
308+
'explanationId': None,
309+
'response': {
310+
'exception': None,
311+
'error_type': None,
312+
'message': None,
313+
'status_code': None,
314+
'status_text': None,
315+
},
316+
'rh_user_has_seat': None,
317+
'rh_user_org_id': None,
318+
},
319+
'generation': {
320+
'duration': None,
321+
'exception': {'error_code': None, 'exception_class': None, 'message': None},
322+
'hostname': None,
323+
'imageTags': None,
324+
'path': None,
325+
'request': {
326+
'generationId': None,
327+
'wizardId': None,
328+
},
329+
'generationId': None,
330+
'wizardId': None,
331+
'response': {
332+
'exception': None,
333+
'error_type': None,
334+
'message': None,
335+
'status_code': None,
336+
'status_text': None,
337+
},
338+
'rh_user_has_seat': None,
339+
'rh_user_org_id': None,
340+
'timestamp': None,
341+
},
301342
}

ansible_wisdom/ai/api/utils/segment.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,17 @@
1414

1515
import logging
1616
import platform
17+
import time
1718
from typing import Any, Dict
1819

1920
from django.conf import settings
21+
from django.urls import reverse
2022
from django.utils import timezone
2123
from segment import analytics
2224
from segment.analytics import Client
2325

2426
from ansible_ai_connect.healthcheck.version_info import VersionInfo
27+
from ansible_ai_connect.main.utils import anonymize_request_data
2528
from ansible_ai_connect.users.models import User
2629

2730
from .seated_users_allow_list import ALLOW_LIST
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# Copyright Red Hat
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
import platform
17+
import time
18+
19+
from segment import analytics
20+
from django.urls import reverse
21+
from django.utils import timezone
22+
from django.conf import settings
23+
from rest_framework.exceptions import ErrorDetail
24+
from ansible_ai_connect.ai.api.utils.analytics_telemetry_model import (
25+
AnalyticsRecommendationGenerated,
26+
AnalyticsRecommendationTask,
27+
AnalyticsTelemetryEvents,
28+
)
29+
from ansible_ai_connect.ai.api.utils.segment import base_send_segment_event
30+
31+
from ansible_ai_connect.ai.api.utils import segment_analytics_telemetry
32+
33+
from .seated_users_allow_list import ALLOW_LIST
34+
from ansible_ai_connect.ai.api.utils.segment import redact_seated_users_data
35+
36+
from ansible_ai_connect.healthcheck.version_info import VersionInfo
37+
from ansible_ai_connect.main.utils import anonymize_request_data
38+
39+
logger = logging.getLogger(__name__)
40+
version_info = VersionInfo()
41+
42+
def on_segment_error(error, _):
43+
logger.error(f'An error occurred in sending data to Segment: {error}')
44+
45+
46+
def on_segment_analytics_error(error, _):
47+
logger.error(f'An error occurred in sending analytics data to Segment: {error}')
48+
49+
50+
51+
52+
53+
54+
class EventRecorder:
55+
fields_to_preserve = []
56+
57+
@staticmethod
58+
def from_request(request):
59+
classMapping = {
60+
reverse('explanations'): ExplanationEventRecorder,
61+
reverse('completions'): CompletionEventRecorder
62+
}
63+
64+
base_class = classMapping.get(request.path)
65+
if not base_class:
66+
return
67+
obj = base_class()
68+
obj.set_request(request)
69+
obj.set_request_data(request)
70+
return obj
71+
72+
73+
74+
def __init__(self,):
75+
# def __init__(self, request=None, response=None, request_data=None):
76+
self.start_time = time.time()
77+
self.request_data: dict[str, Any] = {}
78+
self.model_name: str = ""
79+
self.event_name: str = "not-set"
80+
self.exception: bool = False
81+
self.path: str = ""
82+
self.response: dict[str, str] = {}
83+
self.timestamp = timezone.now().isoformat()
84+
self.hostname: str = platform.node()
85+
self.imageTags: str = version_info.image_tags
86+
self.data = {}
87+
88+
def set_request_data(self, request):
89+
if request.content_type == 'application/json':
90+
try:
91+
request_data = (
92+
json.loads(request.body.decode("utf-8")) if request.body else {}
93+
)
94+
self.request_data = anonymize_request_data(request_data)
95+
except Exception: # when an invalid json or an invalid encoding is detected
96+
pass
97+
98+
def set_request(self, request):
99+
self.path = request.path
100+
101+
self.event_name = EventRecorder.create_event_name_from_path(request.path)
102+
self.user = request.user
103+
self.groups = list(self.user.groups.values_list('name', flat=True))
104+
self.rh_user_has_seat = getattr(self.user, 'rh_user_has_seat', False)
105+
self.rh_user_org_id = getattr(self.user, 'org_id', None)
106+
107+
@staticmethod
108+
def create_event_name_from_path(path):
109+
try:
110+
event_name = path.rstrip("/").split("/")[-1]
111+
if event_name and event_name.endswith("s"):
112+
event_name = event_name[:-1]
113+
return event_name
114+
except Exception:
115+
return "unknown"
116+
117+
def set_exception(self, exc):
118+
error_code = getattr(exc, 'default_code', None)
119+
self.exception = {
120+
"error_code": error_code,
121+
"exception_class": type(exc).__name__,
122+
"message": str(exc),
123+
}
124+
125+
def set_response(self, response):
126+
if response.status_code >= 400 and getattr(response, 'content', None):
127+
message = response.content.decode()
128+
else:
129+
message = ""
130+
self.data = response.data
131+
132+
self.response = {
133+
# See main.exception_handler.exception_handler_with_error_type
134+
# That extracts 'default_code' from Exceptions and stores it
135+
# in the Response.
136+
"error_type": getattr(response, 'error_type', None),
137+
"message": message,
138+
"status_code": response.status_code,
139+
"status_text": getattr(response, 'status_text', None),
140+
}
141+
142+
def event(self):
143+
e = {
144+
"duration": round((time.time() - self.start_time) * 1000, 2),
145+
"event_name": self.event_name,
146+
"exception": self.exception,
147+
"hostname": self.hostname,
148+
"imageTags": self.imageTags,
149+
"path": self.path,
150+
"response": self.response,
151+
"rh_user_has_seat": self.rh_user_has_seat,
152+
"rh_user_org_id": self.rh_user_org_id,
153+
"timestamp": self.timestamp,
154+
}
155+
e |= {k: v for k, v in self.request_data.items() if k in self.field_to_preserve}
156+
return e
157+
158+
# Note: It could be nice to move the send*() methods somewhere else
159+
def send(self):
160+
print("sending!")
161+
e = self.event()
162+
163+
164+
if self.rh_user_has_seat:
165+
allow_list = ALLOW_LIST.get(self.event_name)
166+
167+
if allow_list:
168+
e = redact_seated_users_data(e, allow_list)
169+
else:
170+
# If event should be tracked, please update ALLOW_LIST appropriately
171+
logger.error(
172+
f'It is not allowed to track {self.event_name} events for seated users'
173+
)
174+
175+
if settings.SEGMENT_WRITE_KEY:
176+
if not analytics.write_key:
177+
analytics.write_key = settings.SEGMENT_WRITE_KEY
178+
analytics.debug = settings.DEBUG
179+
analytics.gzip = True # Enable gzip compression
180+
# analytics.send = False # for code development only
181+
analytics.on_error = on_segment_error
182+
183+
base_send_segment_event(e, self.event_name, self.user, analytics)
184+
185+
186+
def send_analytics(self):
187+
if settings.SEGMENT_ANALYTICS_WRITE_KEY:
188+
if not segment_analytics_telemetry.write_key:
189+
segment_analytics_telemetry.write_key = settings.SEGMENT_ANALYTICS_WRITE_KEY
190+
segment_analytics_telemetry.debug = settings.DEBUG
191+
segment_analytics_telemetry.gzip = True # Enable gzip compression
192+
# segment_analytics_telemetry.send = False # for code development only
193+
segment_analytics_telemetry.on_error = on_segment_analytics_error
194+
195+
196+
class ExplanationEventRecorder(EventRecorder):
197+
fields_to_preserve = {"explanationId": "explanationId"}
198+
199+
class GenerationEventRecorder(EventRecorder):
200+
fields_to_preserve = {"generationId": "generationId"}
201+
202+
class CompletionEventRecorder(EventRecorder):
203+
fields_to_preserve = {
204+
"context": "contex",
205+
"prompt": "prompt",
206+
"model": "modelName", # TODO
207+
"metadata": "metadata",
208+
"suggestionId": "suggestionId",
209+
"metadata": "metadata",
210+
"_promptType": "promptType",
211+
}
212+
213+
def send_analytics(self):
214+
super().send_analytics()
215+
response_data = self.data.copy()
216+
217+
if isinstance(response_data, dict):
218+
predictions = response_data.get('predictions')
219+
message = response_data.get('message')
220+
if isinstance(message, ErrorDetail):
221+
message = str(message)
222+
model_name = response_data.get('model', self.model_name)
223+
# For other error cases, remove 'model' in response data
224+
if self.response["status_code"] >= 400:
225+
response_data.pop('model', None)
226+
# Collect analytics telemetry, when tasks exist.
227+
tasks = getattr(self.data, 'tasks', [])
228+
if len(tasks) > 0:
229+
send_segment_analytics_event(
230+
AnalyticsTelemetryEvents.RECOMMENDATION_GENERATED,
231+
lambda: AnalyticsRecommendationGenerated(
232+
tasks=[
233+
AnalyticsRecommendationTask(
234+
collection=task.get('collection', ''),
235+
module=task.get('module', ''),
236+
)
237+
for task in tasks
238+
],
239+
rh_user_org_id=self.rh_user_org_id,
240+
suggestion_id=request_suggestion_id,
241+
model_name=self.model_name,
242+
),
243+
request.user,
244+
getattr(request, '_ansible_extension_version', None),
245+
)

0 commit comments

Comments
 (0)