OpossumLogger.cpp
Go to the documentation of this file.
1/*
2 * This file is part of ArmarX.
3 *
4 * ArmarX is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License version 2 as
6 * published by the Free Software Foundation.
7 *
8 * ArmarX is distributed in the hope that it will be useful, but
9 * WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 *
16 * @package RobotAPI::ArmarXObjects::OpossumLogger
17 * @author Joana Plewnia ( joana dot plewnia at kit dot edu )
18 * @date 2026
19 * @copyright http://www.gnu.org/licenses/gpl-2.0.txt
20 * GNU General Public License
21 */
22
23#include "OpossumLogger.h"
24
25#include <csignal>
26
27#include <pthread.h>
28
29#include <algorithm>
30#include <fstream>
31#include <map>
32#include <optional>
33#include <utility>
34
35#include <SimoxUtility/json/json.hpp>
36
38
42#include <RobotAPI/libraries/armem_skills/aron/Skill.aron.generated.h>
44
45// cpp-httplib, vendored for the ArMem REST persistence strategy.
47
48namespace armarx
49{
50
51 namespace
52 {
53 /// How many snapshot IDs to remember for duplicate detection.
54 constexpr std::size_t SeenCapacity = 512;
55
56 /// How long to keep draining the queue when shutting down.
57 constexpr std::chrono::milliseconds DrainTimeout{1000};
58 } // namespace
59
61 {
62 // ~SubscriptionHandle does not release by itself, so without this the
63 // memory listener would keep calling onSkillEventUpdate() on a
64 // half-destroyed component whenever onDisconnectComponent() did not run.
65 // Releasing twice is safe: MemoryListener::unsubscribe() guards on the
66 // handle's `valid` flag.
67 subscription.release();
68 stopSender();
69 }
70
71 std::string
73 {
74 return GetDefaultName();
75 }
76
77 std::string
79 {
80 return "OpossumLogger";
81 }
82
85 {
88
89 defs->optional(p.enabled, "opossum.Enabled", "Whether to report skill events at all.");
90 defs->optional(p.robotName,
91 "opossum.RobotName",
92 "Value of the 'robot_name' field. The server sorts the logs of the "
93 "different robots by this name.");
94 defs->optional(p.host, "opossum.Host", "Host of the OPOSSUM summary server.");
95 defs->optional(p.port, "opossum.Port", "Port of the OPOSSUM summary server.");
96 defs->optional(p.path,
97 "opossum.Path",
98 "Path the log lines are POSTed to (without leading slash).");
99 defs->optional(p.timeoutMs,
100 "opossum.TimeoutMs",
101 "Connect, read and write timeout of a single POST in milliseconds.");
102 defs->optional(p.queueSize,
103 "opossum.QueueSize",
104 "Maximal number of pending lines. When the server is slow or "
105 "unreachable, the oldest lines are dropped.");
106 defs->optional(p.outputFile,
107 "opossum.OutputFile",
108 "If set, every reported line is also appended to this file. Useful to "
109 "record an episode in the OPOSSUM log format. Empty = disabled.");
111 defs->optional(p.skillFilter,
112 "opossum.SkillFilter",
113 "Comma-separated skills that are not reported. These are pure body and "
114 "gaze control primitives, which say nothing at scene level.");
115 defs->optional(p.resolveObjectNames,
116 "opossum.ResolveObjectNames",
117 "Resolve object classes to their natural-language names using "
118 "PriorKnowledgeData. If false, the class name is humanized instead.");
119
120 defs->optional(p.memoryName, "mem.SkillMemoryName", "Name of the skill memory.");
121 defs->optional(p.coreSegmentName,
122 "mem.CoreSegmentName",
123 "Name of the core segment holding the skill events.");
124
125 return defs;
126 }
127
128 void
130 {
131 skillEventID = armem::MemoryID(p.memoryName, p.coreSegmentName);
132
134 if (p.resolveObjectNames)
135 {
136 lookup = spokenNames.asLookup();
137 }
138 renderer = std::make_unique<opossum::SkillEventRenderer>(
139 opossum::SkillEventRenderer::ParseSkillList(p.skillFilter), std::move(lookup));
140
141 if (not p.enabled)
142 {
143 ARMARX_IMPORTANT << "Reporting is disabled (opossum.Enabled = false).";
144 }
145 }
146
147 void
149 {
150 if (not p.enabled)
151 {
152 return;
153 }
154
155 // Wait for the memory to become available and add it as a dependency.
156 ARMARX_IMPORTANT << "Waiting for memory '" << p.memoryName << "' ...";
157 try
158 {
159 skillMemoryReader = memoryNameSystem().useReader(p.memoryName);
160 }
161 catch (const armem::error::ArMemError& e)
162 {
163 ARMARX_ERROR << "Could not use the memory '" << p.memoryName
164 << "'. No skill events will be reported. Reason: " << e.what();
165 return;
166 }
167
168 startSender();
169
170 subscription =
171 memoryNameSystem().subscribe(skillEventID, this, &OpossumLogger::onSkillEventUpdate);
172
173 // `MemoryID` already prints itself in quotes.
174 ARMARX_IMPORTANT << "Reporting skill events from " << skillEventID << " as '" << p.robotName
175 << "' to http://" << p.host << ":" << p.port << "/" << p.path << ".";
176 }
177
178 void
180 {
181 subscription.release();
182 stopSender();
183 }
184
185 void
187 {
188 stopSender();
189 }
190
191 bool
192 OpossumLogger::isNew(const armem::MemoryID& snapshotID)
193 {
194 const std::string id = snapshotID.str();
195
196 std::scoped_lock lock(seenMutex);
197 if (not seen.insert(id).second)
198 {
199 return false;
200 }
201 seenOrder.push_back(id);
202 if (seenOrder.size() > SeenCapacity)
203 {
204 seen.erase(seenOrder.front());
205 seenOrder.pop_front();
206 }
207 return true;
208 }
209
210 std::map<armem::MemoryID, armem::wm::EntityInstance>
211 OpossumLogger::resolve(const std::vector<armem::MemoryID>& snapshotIDs)
212 {
213 // Deliberately not memoryNameSystem().resolveEntityInstances(): that
214 // re-resolves the MNS per call, ice_ping()ing every proxy of every
215 // registered memory server, and this component runs off-robot.
216 armem::client::QueryResult result = skillMemoryReader.queryMemoryIDs(snapshotIDs);
217 if (not result.success)
218 {
219 ARMARX_WARNING << deactivateSpam(10) << "Could not query " << snapshotIDs.size()
220 << " skill event snapshot(s): " << result.errorMessage;
221 return {};
222 }
223
224 std::map<armem::MemoryID, armem::wm::EntityInstance> instances;
225 for (const armem::MemoryID& snapshotID : snapshotIDs)
226 {
227 try
228 {
229 instances.emplace(snapshotID, result.memory.getSnapshot(snapshotID).getInstance(0));
230 }
231 catch (const armem::error::ArMemError& e)
232 {
233 // The working memory holds a bounded history per entity, so a
234 // snapshot can be evicted before we get to it.
235 ARMARX_WARNING << deactivateSpam(10) << "Skill event " << snapshotID
236 << " is not in the memory (anymore): " << e.what();
237 }
238 }
239 return instances;
240 }
241
242 void
243 OpossumLogger::onSkillEventUpdate(const armem::MemoryID& /*subscriptionID*/,
244 const std::vector<armem::MemoryID>& updatedSnapshotIDs)
245 {
246 std::vector<armem::MemoryID> newSnapshotIDs;
247 for (const armem::MemoryID& snapshotID : updatedSnapshotIDs)
248 {
249 // The entity name is the skill name, so noise is dropped before it
250 // costs a query -- and never displaces a real event from `seen`.
251 if (renderer->isNoiseSkill(snapshotID.entityName))
252 {
253 continue;
254 }
255 // The same snapshot may be announced more than once; a duplicate
256 // line would be a duplicate event to the summariser.
257 if (isNew(snapshotID))
258 {
259 newSnapshotIDs.push_back(snapshotID);
260 }
261 }
262 if (newSnapshotIDs.empty())
263 {
264 return;
265 }
266
267 // Resolving in one batch is a single query to the memory server.
268 std::map<armem::MemoryID, armem::wm::EntityInstance> instances;
269 try
270 {
271 instances = resolve(newSnapshotIDs);
272 }
273 catch (const std::exception& e)
274 {
275 ARMARX_WARNING << deactivateSpam(10) << "Could not read " << newSnapshotIDs.size()
276 << " skill event snapshot(s): " << e.what();
277 return;
278 }
279
280 std::vector<std::pair<armarx::core::time::DateTime, std::string>> lines;
281 for (const auto& [snapshotID, instance] : instances)
282 {
283 if (instance.data() == nullptr)
284 {
285 continue;
286 }
287
288 try
289 {
290 const auto update =
291 skills::arondto::SkillStatusUpdate::FromAron(instance.data());
292
293 opossum::SkillEvent event;
294 event.skillName = update.skillId.skillName;
295 event.status = update.status;
296 event.executorName = update.executorName;
297 // The snapshot's referenced time is the time of the event;
298 // executionStartedTimestamp is constant over an execution's
299 // lifecycle events and would collapse them onto one instant.
300 event.timestamp = snapshotID.timestamp;
301
302 namespace converter = armarx::aron::data::converter;
303 if (update.parameters)
304 {
305 event.parameters =
306 converter::AronNlohmannJSONConverter::ConvertToNlohmannJSON(
307 update.parameters);
308 }
309 if (update.result)
310 {
311 event.result = converter::AronNlohmannJSONConverter::ConvertToNlohmannJSON(
312 update.result);
313 }
314
315 if (const std::optional<std::string> line = renderer->render(event))
316 {
317 lines.emplace_back(event.timestamp, *line);
318 }
319 }
320 catch (const std::exception& e)
321 {
322 ARMARX_WARNING << deactivateSpam(10) << "Could not render the skill event "
323 << snapshotID << ": " << e.what();
324 }
325 }
326
327 // A batch may contain events of different skills; report them in the
328 // order in which they happened.
329 std::sort(lines.begin(),
330 lines.end(),
331 [](const auto& lhs, const auto& rhs)
332 { return lhs.first.toMicroSecondsSinceEpoch() < rhs.first.toMicroSecondsSinceEpoch(); });
333 for (auto& [time, line] : lines)
334 {
335 enqueue(std::move(line));
336 }
337 }
338
339 void
340 OpossumLogger::enqueue(std::string line)
341 {
342 ARMARX_VERBOSE << line;
343
344 std::size_t dropped = 0;
345 {
346 std::scoped_lock lock(queueMutex);
347 queue.push_back(std::move(line));
348 while (queue.size() > static_cast<std::size_t>(std::max(1, p.queueSize)))
349 {
350 queue.pop_front();
351 dropped = ++droppedLines;
352 }
353 }
354 queueCondition.notify_one();
355
356 if (dropped > 0)
357 {
358 ARMARX_WARNING << deactivateSpam(10) << "Dropped the oldest of more than "
359 << p.queueSize << " pending log lines (" << dropped
360 << " in total). The OPOSSUM server is not keeping up.";
361 }
362 }
363
364 void
365 OpossumLogger::startSender()
366 {
367 if (running.exchange(true))
368 {
369 return;
370 }
371 sender = std::thread(&OpossumLogger::sendLoop, this);
372 }
373
374 void
375 OpossumLogger::stopSender()
376 {
377 if (not running.exchange(false))
378 {
379 return;
380 }
381 queueCondition.notify_all();
382 if (sender.joinable())
383 {
384 sender.join();
385 }
386 }
387
388 void
389 OpossumLogger::sendLoop()
390 {
391 // cpp-httplib ignores SIGPIPE only in its Server constructor, never in
392 // Client, and does not pass MSG_NOSIGNAL; ArmarX installs no SIGPIPE
393 // handler either. Writing to a socket the summary server has already
394 // closed would therefore take down the whole robot process. Blocked for
395 // this thread only rather than process-wide: send() then fails with
396 // EPIPE, which httplib reports as a failed Result, handled below.
397 sigset_t blocked;
398 sigemptyset(&blocked);
399 sigaddset(&blocked, SIGPIPE);
400 pthread_sigmask(SIG_BLOCK, &blocked, nullptr);
401
402 httplib::Client client(p.host, p.port);
403 client.set_connection_timeout(std::chrono::milliseconds(p.timeoutMs));
404 client.set_read_timeout(std::chrono::milliseconds(p.timeoutMs));
405 client.set_write_timeout(std::chrono::milliseconds(p.timeoutMs));
406
407 std::ofstream file;
408 if (not p.outputFile.empty())
409 {
410 file.open(p.outputFile, std::ios::out | std::ios::app);
411 if (not file.is_open())
412 {
413 ARMARX_WARNING << "Could not open '" << p.outputFile
414 << "' for writing. Log lines will only be posted.";
415 }
416 }
417
418 const std::string path = "/" + p.path;
419 bool reachable = true;
420 // Set once the component starts shutting down; until then, pending
421 // lines are still sent, but not forever.
422 std::optional<std::chrono::steady_clock::time_point> drainUntil;
423
424 while (true)
425 {
426 std::string line;
427 {
428 std::unique_lock lock(queueMutex);
429 queueCondition.wait(lock,
430 [this] { return not running.load() or not queue.empty(); });
431 if (queue.empty())
432 {
433 // Shutting down and nothing left to send.
434 break;
435 }
436 if (not running.load())
437 {
438 if (not drainUntil.has_value())
439 {
440 drainUntil = std::chrono::steady_clock::now() + DrainTimeout;
441 }
442 if (std::chrono::steady_clock::now() > *drainUntil)
443 {
444 ARMARX_INFO << "Discarding " << queue.size()
445 << " pending log line(s) on shutdown.";
446 break;
447 }
448 }
449 line = std::move(queue.front());
450 queue.pop_front();
451 }
452
453 if (file.is_open())
454 {
455 file << line << "\n";
456 file.flush();
457 }
458
459 const nlohmann::json message{{"robot_name", p.robotName}, {"message", line}};
460 const std::string payload = message.dump();
461
462 // Only at verbose level: the full request, so what reaches the
463 // server can be checked without capturing the traffic.
464 ARMARX_VERBOSE << "Publishing to http://" << p.host << ":" << p.port << path
465 << " (" << payload.size() << " bytes): " << payload;
466
467 bool posted = false;
468 try
469 {
470 // The server may be turned off; that is expected and must not
471 // affect the robot in any way.
472 const httplib::Result result = client.Post(path, payload, "application/json");
473 // `result` holds no response when the request never completed,
474 // so it must not be dereferenced then. That case means the
475 // server is unreachable, reported by the tracking below.
476 posted = static_cast<bool>(result);
477 if (posted and (result->status < 200 or result->status >= 300))
478 {
479 // The server answered, so it is reachable, but it did not
480 // accept the line -- a wrong opossum.Path, or an error on
481 // its side. Worth its own warning: the summary server is
482 // run by a project partner, we do not see its console, and
483 // every rejected line is lost.
484 ARMARX_WARNING << deactivateSpam(60) << "The OPOSSUM server at http://"
485 << p.host << ":" << p.port << path << " answered "
486 << result->status << " " << result->reason
487 << ". The log line was discarded. If this is a 404, check "
488 << "opossum.Path against the path the server serves.";
489 }
490 else if (posted)
491 {
492 ARMARX_VERBOSE << "Published (" << result->status << " " << result->reason
493 << "), answer: " << result->body;
494 }
495 }
496 catch (const std::exception& e)
497 {
498 ARMARX_WARNING << deactivateSpam(60) << "Failed to post a log line to http://"
499 << p.host << ":" << p.port << path << ": " << e.what();
500 }
501
502 if (posted != reachable)
503 {
504 reachable = posted;
505 if (reachable)
506 {
507 ARMARX_IMPORTANT << "The OPOSSUM server at http://" << p.host << ":" << p.port
508 << path << " is reachable again.";
509 }
510 else
511 {
512 ARMARX_WARNING << "The OPOSSUM server at http://" << p.host << ":" << p.port
513 << path << " is not reachable. Log lines are discarded until "
514 << "it comes back. This is not an error on the robot's side.";
515 }
516 }
517 }
518 }
519
521
522} // namespace armarx
#define ARMARX_REGISTER_COMPONENT_EXECUTABLE(ComponentT, applicationName)
Definition Decoupled.h:29
Default component property definition container.
Definition Component.h:70
std::string getConfigIdentifier()
Retrieve config identifier for this component as set in constructor.
Definition Component.cpp:88
SpamFilterDataPtr deactivateSpam(float deactivationDurationSec=10.0f, const std::string &identifier="", bool deactivate=true) const
disables the logging for the current line for the given amount of seconds.
Definition Logging.cpp:99
void onInitComponent() override
Pure virtual hook for the subclass.
void onDisconnectComponent() override
Hook for subclass.
armarx::PropertyDefinitionsPtr createPropertyDefinitions() override
void onConnectComponent() override
Pure virtual hook for the subclass.
static std::string GetDefaultName()
void onExitComponent() override
Hook for subclass.
std::string getDefaultName() const override
Retrieve default name of component.
std::string str(bool escapeDelimiters=true) const
Get a string representation of this memory ID.
Definition MemoryID.cpp:102
std::string entityName
Definition MemoryID.h:53
Reader useReader(const MemoryID &memoryID)
Use a memory server and get a reader for it.
SubscriptionHandle subscribe(const MemoryID &subscriptionID, Callback Callback)
Base class for all exceptions thrown by the armem library.
Definition ArMemError.h:19
static std::string DefaultNoiseSkillsString()
DefaultNoiseSkills() as a comma-separated string (for property defaults).
static std::set< std::string > ParseSkillList(const std::string &commaSeparated)
Split a comma-separated list into a set, trimming whitespace.
#define ARMARX_INFO
The normal logging level.
Definition Logging.h:179
#define ARMARX_IMPORTANT
The logging level for always important information, but expected behaviour (in contrast to ARMARX_WAR...
Definition Logging.h:188
#define ARMARX_ERROR
The logging level for unexpected behaviour, that must be fixed.
Definition Logging.h:194
#define ARMARX_WARNING
The logging level for unexpected behaviour, but not a serious problem.
Definition Logging.h:191
#define ARMARX_VERBOSE
The logging level for verbose information.
Definition Logging.h:185
bool update(mongocxx::collection &coll, const nlohmann::json &query, const nlohmann::json &update)
Definition mongodb.cpp:68
std::function< std::optional< std::string >( const std::string &dataset, const std::string &className)> SpokenNameLookup
Resolves an object class to its natural-language ("spoken") name.
This file offers overloads of toIce() and fromIce() functions for STL container types.
IceUtil::Handle< class PropertyDefinitionContainer > PropertyDefinitionsPtr
PropertyDefinitions smart pointer type.
auto & getSnapshot(const MemoryID &snapshotID)
Retrieve an entity snapshot.
wm::Memory memory
The slice of the memory that matched the query.
Definition Query.h:58
std::string body
Definition httplib.h:671
std::string reason
Definition httplib.h:669