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

CE-136 JSON export download logging #3178

Draft
wants to merge 3 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions kbase-extension/static/kbase/js/util/Logging.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
define([], () => {
/**
*
* @param {string} code - executable Python code in a string
* @returns {Promise<string>} - A promise which will result in a string the value
* of which was that returned by the last statement
* in the Python code.
*/
async function executePython(code) {
Copy link
Member

Choose a reason for hiding this comment

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

This seems reasonable. I think there's some other error catching / wait-until-kernel-is-alive code elsewhere that we can either repurpose or just make use of.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I'll hunt for that.

return new Promise((resolve, reject) => {
Jupyter.notebook.kernel.execute(code, {
iopub: {
output: (msg) => {
if ('content' in msg) {
if ('data' in msg.content) {
if ('text/plain' in msg.content.data) {
resolve(msg.content.data['text/plain']);
} else {
reject(new Error('Cannot locate "text/plain" response in "content.data" of output message'));
}
} else if ('ename' in msg.content) {
reject(new Error(`Error executing logging kernel command: ${msg.content.ename}, ${msg.content.evalue}`))
} else {
reject(new Error('Cannot locate "data" or "ename" in output message "content"'))
}
} else {
reject(new Error('Cannot locate "content" in output message'));
}
}
}
}, {
store_history: false,
silent: false
});
});
}
class Logging {
async execBackendLogging(event, data, level) {
// Is it really possible to go to this code if the nodebook
// and kernel have not been initialized?
if (!Jupyter || !Jupyter.notebook || !Jupyter.notebook.kernel) {
console.warn('Jupyter not fully set up, cannot log');
return;
}

if (!Jupyter.notebook.kernel.is_connected()) {
console.warn('Jupyter kernel not connected, cannot log');
return
}

const code = `
from biokbase.narrative.common.kblogging import log_ui_event
log_ui_event("${event}", ${JSON.stringify(data)}, "${level}")
`

// Wrap in try/catch to be resilient to logging failures.
try {
return await executePython(code);
} catch (ex) {
console.error('Error executing logging code', ex);
}
}
log(event, data, level) {
return this.execBackendLogging(event, data, level);
}
// One method per logging level. These are the methods
// that should be used for logging.
debug(event, data) {
return this.log(event, data, 'debug');
}
info(event, data) {
return this.log(event, data, 'info');
}
warning(event, data) {
return this.log(event, data, 'warning');
}
error(event, data) {
return this.log(event, data, 'error');
}
critical(event, data) {
return this.log(event, data, 'critical');
}
}

return Logging
})
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ define([
'kbase-client-api',
'kbase-generic-client-api',
'base/js/namespace',
], (Promise, KBWidget, $, Config, kbase_client_api, GenericClient, Jupyter) => {
'kbaseAuthenticatedWidget',
'util/Logging'
], (Promise, KBWidget, $, Config, kbase_client_api, GenericClient, Jupyter, kbaseAuthenticatedWidget, Logging) => {
'use strict';
return KBWidget({
name: 'kbaseNarrativeDownloadPanel',
parent: kbaseAuthenticatedWidget,
version: '1.0.0',
options: {
token: null,
Expand Down Expand Up @@ -126,16 +129,8 @@ define([
.addClass('kb-data-list-btn')
.append('JSON')
.click(() => {
const urlSuffix =
'/download?' +
'ref=' +
encodeURIComponent(self.ref) +
'&url=' +
encodeURIComponent(self.wsUrl) +
'&wszip=1' +
'&name=' +
encodeURIComponent(self.objName + '.JSON.zip');
self.downloadFile(urlSuffix);

self.downloadObject()
})
);
$btnTd.append(
Expand All @@ -153,6 +148,22 @@ define([
self.$statusDiv.append(self.$statusDivContent);
downloadPanel.append(self.$statusDiv.hide());
},

downloadObject: async function () {
// First start the download
const name = `${this.objName}.JSON.zip`;

const params = {
ref: this.ref, url: this.wsUrl, wszip: 1, name
}
this.downloadFile2(params);

// Then log the download. This is asynchronous as the logger waits for the kernel to respond with the
// result, which should be a log id (uuid).
const data = {ref: this.ref, format: "json"}
const result = await new Logging().info('download-object', data);
console.log(`Logged download ${result}`);
},

prepareDownloaders: function (type) {
const ret = [];
Expand Down Expand Up @@ -330,31 +341,19 @@ define([
},

downloadFile: function (urlSuffix) {
const self = this;
if (self.useDynamicDownloadSupport) {
const genericClient = new GenericClient(
self.srvWizURL,
{ token: self.token },
null,
false
);
genericClient.sync_call(
'ServiceWizard.get_service_status',
[{ module_name: 'NarrativeDownloadSupport', version: 'dev' }],
(data) => {
const urlPrefix = data[0]['url'];
self.downloadFileInner(urlPrefix + urlSuffix);
},
(error) => {
console.error(error);
self.showError(error);
}
);
} else {
self.downloadFileInner(self.exportURL + urlSuffix);
this.downloadFileInner(this.exportURL + urlSuffix);
},

downloadFile2: function (params) {
const url = new URL(`${this.exportURL}/download`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}

this.downloadFileInner(url.toString());
},


downloadFileInner: function (url) {
console.log('Downloading url=' + url);
const hiddenIFrameID = 'hiddenDownloader';
Expand Down
108 changes: 105 additions & 3 deletions src/biokbase/narrative/common/kblogging.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,22 @@

"""
import collections
import json
import logging
from logging import handlers
import os
import threading
import time
import uuid
from datetime import datetime, timezone
from logging import handlers

import biokbase

# Local
from .util import kbase_env
from . import log_proxy
from .log_common import format_event
from .narrative_logger import NarrativeLogger
# Local
from .util import kbase_env

__author__ = "Dan Gunter <[email protected]>"
__date__ = "2014-07-31"
Expand All @@ -29,6 +34,8 @@
KBASE_TMP_DIR = "/tmp"
KBASE_TMP_LOGFILE = os.path.join(KBASE_TMP_DIR, "kbase-narrative.log")

KBASE_UI_LOGFILE = os.path.join(KBASE_TMP_DIR, "kbase-narrative-ui.json")

# env var with location of proxy config file
KBASE_PROXY_ENV = "KBASE_PROXY_CONFIG"

Expand Down Expand Up @@ -200,6 +207,7 @@ def init_handlers():

if not _has_handler_type(g_log, logging.FileHandler):
hndlr = logging.FileHandler(KBASE_TMP_LOGFILE)
# hndlr = logging.StreamHandler(sys.stdout)
fmtr = logging.Formatter("%(levelname)s %(asctime)s %(name)s %(message)s")
hndlr.setFormatter(fmtr)
g_log.addHandler(hndlr)
Expand Down Expand Up @@ -252,3 +260,97 @@ def __init__(self, is_fatal, where="unknown location", what="unknown condition")
msg = format_event("ui.error", info)
log_method = (self.ui_log.error, self.ui_log.critical)[is_fatal]
log_method(msg)

def epoch_time_millis():
epoch_time = datetime.now(tz=timezone.utc).timestamp()
return int(epoch_time * 1000)

def get_username():
token = biokbase.auth.get_auth_token()
if token is None:
return None
try:
user_info = biokbase.auth.get_user_info(token)
return user_info.get("user", None)
except BaseException:
return None

def log_ui_event(event: str, data: dict, level: str):
# We use a separate logger, configured to save the
# entire message as a simple string ... and that string
# is a JSON-encoded message object.
# The resulting log file, then, is y it is a JSON stream format, since it
# contains multiple objects in sequence.
ui_log = get_logger("narrative_ui_json")
log_id = str(uuid.uuid4())
if not _has_handler_type(ui_log, logging.FileHandler):
Copy link
Member

Choose a reason for hiding this comment

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

I think before going too far down this rabbit hole, we should talk to devops about what's the most reasonable logging format for their use. Though some local file logging would be good, too.

Also, file handling / formatting should be a more global thing for all logging.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I just wanted to make sure that this prototyping work actually produced a JSON log in a minimal fashion with the simplest implementation -- a file in the same place we are currently logging (/tmp). And I agree it should be global. I figured, get it working in this case, then later switch over existing logging entry points to use it too.

I'm sure on the table still is logging to a file in a mounted directory and having an external process grab those logs. I don't think that is a good solution for Narrative logging, but it should be considered, as it is a traditional logging technique.

# Here we may change the logging handler to something like HTTP, syslog, io stream,
# see https://docs.python.org/3/library/logging.handlers.html
handler = logging.FileHandler(KBASE_UI_LOGFILE)
formatter = logging.Formatter("%(message)s")
handler.setFormatter(formatter)
ui_log.addHandler(handler)


#
# The logging message is simply what, when, who, where, and ... data
# There could be other interesting information such as
#
[_, workspace_id, _, object_id] = kbase_env.narrative.split(".")


message = json.dumps({
# If logs are combined, we need to tie log entries to
# a specific version of a service in a specific environment.
Comment on lines +303 to +304
Copy link
Member

Choose a reason for hiding this comment

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

+1

"service": "narrative",
"version": biokbase.narrative.version(),
"environment": kbase_env.env,
# General log entry; information that any log entry
# will need.
# id helps create a unique reference to a log entry; perhaps
# should be uuid, service + uuid, or omitted and only created
# by a logging repository service.
"id": log_id,
"timestamp": epoch_time_millis(),
# The actual, specific event. The event name is a simple
# string, the data is a dict or serializable class whose
# definition is implied by the event name.
"event": {
"name": event,
# the event context captures information common to instances
# of this kind of event. As a narrative ui event, part of the context
# is the narrative object, the current user, and the current user's
# browser. Clearly more could be captured here, e.g. the browser model,
# narrative session id, etc.
"context": {
# I tried to update kbase_env to reflect the current narrative ref,
# but no no avail so far. The kbase_env here is not the same as the
# one used when saving a narrative and getting the new version, so it does
# not reflect an updated narrative object version.
# If that isn't possible, we can store the ws and object id instead.
"narrative_ref": kbase_env.narrative_ref,
# Log entries for authenticated contexts should identify the user
"username": kbase_env.user,
# Log entries resulting from a network call can/should identify
# the ip address of the caller
"client_ip": kbase_env.client_ip
# could be more context, like the jupyter / ipython / etc. versions
},
# This is the specific data sent in this logging event
"data": data
}
})

if level == 'debug':
ui_log.debug(message)
elif level == 'info':
ui_log.info(message)
elif level == 'warning':
ui_log.warning(message)
elif level == 'error':
ui_log.error(message)
elif level == 'critical':
ui_log.critical(message)
else:
raise ValueError(f"log level must be one of debug, info, warning, error, or critical; it is '{level}'")
return log_id
8 changes: 4 additions & 4 deletions src/biokbase/narrative/common/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@

import os
import re
import requests
import uuid

from setuptools import Command
from .kvp import KVP_EXPR, parse_kvp
from biokbase.workspace.client import Workspace as WS2
from biokbase.workspace.baseclient import ServerError


def kbase_debug_mode():
Expand All @@ -35,6 +33,7 @@ class _KBaseEnv(object):
env_workspace = "KB_WORKSPACE_ID"
env_user = "KB_USER_ID"
env_env = "KB_ENVIRONMENT"
env_narrative_ref = "KB_NARRATIVE_REF"

_defaults = {
"auth_token": "none",
Expand All @@ -44,6 +43,7 @@ class _KBaseEnv(object):
"user": "anonymous",
"workspace": "none",
"env": "none",
"narrative_ref": "none"
}

def __getattr__(self, name):
Expand Down
10 changes: 6 additions & 4 deletions src/biokbase/narrative/contents/kbasewsmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ def get(self, path, content=True, type=None, format=None):
util.kbase_env.narrative = "ws.{}.obj.{}".format(
ref.wsid, ref.objid
)
util.kbase_env.narrative_ref = f"{ref.wsid}/{ref.objid}/{nar_obj['info'][4]}"
util.kbase_env.workspace = model["content"].metadata.ws_name
self.narrative_logger.narrative_open(
"{}/{}".format(ref.wsid, ref.objid), nar_obj["info"][4]
Expand Down Expand Up @@ -289,20 +290,21 @@ def save(self, model, path):

try:
ref = self._parse_path(path)
result = self.write_narrative(ref, nb, self.get_userid())
nb, workspace_id, object_id, object_version = self.write_narrative(ref, nb, self.get_userid())

new_id = "ws.%s.obj.%s" % (result[1], result[2])
new_id = "ws.%s.obj.%s" % (workspace_id, object_id)
util.kbase_env.narrative = new_id
new_ref = f"{workspace_id}/{object_id}/{object_version}"
util.kbase_env.narrative_ref = new_ref

nb = result[0]
self.validate_notebook_model(model)
validation_message = model.get("message", None)

model = self.get(path, content=False)
if validation_message:
model["message"] = validation_message
self.narrative_logger.narrative_save(
"{}/{}".format(result[1], result[2]), result[3]
"{}/{}".format(workspace_id, object_id), object_version
)
return model
except WorkspaceError as err:
Expand Down