Преглед на файлове

Merge pull request #6952 from adityahase/recorder

feat: Recorder
version-14
Faris Ansari преди 6 години
committed by GitHub
родител
ревизия
550e601186
No known key found for this signature in database GPG ключ ID: 4AEE18F83AFDEB23
променени са 16 файла, в които са добавени 1820 реда и са изтрити 955 реда
  1. +5
    -1
      cypress/support/commands.js
  2. +5
    -1
      frappe/app.py
  3. +20
    -1
      frappe/commands/site.py
  4. +0
    -0
      frappe/core/page/recorder/__init__.py
  5. +26
    -0
      frappe/core/page/recorder/recorder.js
  6. +23
    -0
      frappe/core/page/recorder/recorder.json
  7. +3
    -0
      frappe/public/build.json
  8. +251
    -0
      frappe/public/js/frappe/recorder/RecorderDetail.vue
  9. +11
    -0
      frappe/public/js/frappe/recorder/RecorderRoot.vue
  10. +284
    -0
      frappe/public/js/frappe/recorder/RequestDetail.vue
  11. +39
    -0
      frappe/public/js/frappe/recorder/recorder.js
  12. +170
    -0
      frappe/recorder.py
  13. +121
    -0
      frappe/tests/test_recorder.py
  14. +3
    -2
      package.json
  15. +2
    -1
      requirements.txt
  16. +857
    -949
      yarn.lock

+ 5
- 1
cypress/support/commands.js Целия файл

@@ -49,4 +49,8 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype='Data') => {
} else {
return cy.get('@input').type(value);
}
});
});

Cypress.Commands.add('awesomebar', (text) => {
cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, { delay: 100 });
});

+ 5
- 1
frappe/app.py Целия файл

@@ -24,6 +24,7 @@ from frappe.middlewares import StaticDataMiddleware
from frappe.utils.error import make_error_snapshot
from frappe.core.doctype.comment.comment import update_comments_in_parent_after_request
from frappe import _
import frappe.recorder

local_manager = LocalManager([frappe.local])

@@ -41,7 +42,6 @@ class RequestContext(object):
def __exit__(self, type, value, traceback):
frappe.destroy()


@Request.application
def application(request):
response = None
@@ -51,6 +51,8 @@ def application(request):

init_request(request)

frappe.recorder.record()

if frappe.local.form_dict.cmd:
response = frappe.handler.handle()

@@ -91,6 +93,8 @@ def application(request):
if response and hasattr(frappe.local, 'cookie_manager'):
frappe.local.cookie_manager.flush_cookies(response=response)

frappe.recorder.dump()

frappe.destroy()

return response


+ 20
- 1
frappe/commands/site.py Целия файл

@@ -569,6 +569,23 @@ def browse(context, site):
else:
click.echo("\nSite named \033[1m{}\033[0m doesn't exist\n".format(site))


@click.command('start-recording')
@pass_context
def start_recording(context):
for site in context.sites:
frappe.init(site=site)
frappe.recorder.start()


@click.command('stop-recording')
@pass_context
def stop_recording(context):
for site in context.sites:
frappe.init(site=site)
frappe.recorder.stop()


commands = [
add_system_manager,
backup,
@@ -592,5 +609,7 @@ commands = [
_use,
set_last_active_for_user,
publish_realtime,
browse
browse,
start_recording,
stop_recording,
]

+ 0
- 0
frappe/core/page/recorder/__init__.py Целия файл


+ 26
- 0
frappe/core/page/recorder/recorder.js Целия файл

@@ -0,0 +1,26 @@
frappe.pages['recorder'].on_page_load = function(wrapper) {
frappe.ui.make_app_page({
parent: wrapper,
title: 'Recorder',
single_column: true
});

frappe.recorder = new Recorder(wrapper);
$(wrapper).bind('show', function() {
frappe.recorder.show();
});

frappe.require('/assets/js/frappe-recorder.min.js');
};

class Recorder {
constructor(wrapper) {
this.wrapper = $(wrapper);
this.container = this.wrapper.find('.layout-main-section');
this.container.append($('<div class="recorder-container"></div>'));
}

show() {

}
}

+ 23
- 0
frappe/core/page/recorder/recorder.json Целия файл

@@ -0,0 +1,23 @@
{
"content": null,
"creation": "2019-02-08 08:17:45.392739",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2019-02-08 08:23:04.416426",
"modified_by": "Administrator",
"module": "Core",
"name": "recorder",
"owner": "Administrator",
"page_name": "Recorder",
"roles": [
{
"role": "Administrator"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Recorder"
}

+ 3
- 0
frappe/public/build.json Целия файл

@@ -21,6 +21,9 @@
"js/frappe-vue.min.js": [
"public/js/frappe_vue.js"
],
"js/frappe-recorder.min.js": [
"public/js/frappe/recorder/recorder.js"
],
"js/frappe-web.min.js": [
"public/js/frappe/class.js",
"public/js/frappe/polyfill.js",


+ 251
- 0
frappe/public/js/frappe/recorder/RecorderDetail.vue Целия файл

@@ -0,0 +1,251 @@
<template>
<div>
<div class="frappe-list">
<div class="list-filters"></div>
<div style="margin-bottom:9px" class="list-toolbar-wrapper hide">
<div class="list-toolbar btn-group" style="display:inline-block; margin-right: 10px;"></div>
</div>
<div style="clear:both"></div>
<div class="filter-list">
<div class="tag-filters-area">
<div class="active-tag-filters">
<button class="btn btn-default btn-xs add-filter text-muted">
Add Filter
</button>
</div>
</div>
<div class="filter-edit-area"></div>
<div class="sort-selector">
<div class="dropdown"><a class="text-muted dropdown-toggle small" data-toggle="dropdown"><span class="dropdown-text">{{ columns.filter(c => c.slug == query.sort)[0].label }}</span></a>
<ul class="dropdown-menu">
<li v-for="(column, index) in columns.filter(c => c.sortable)" :key="index" @click="query.sort = column.slug"><a class="option">{{ column.label }}</a></li>
</ul>
</div>
<button class="btn btn-default btn-xs btn-order">
<span class="octicon text-muted" :class="query.order == 'asc' ? 'octicon-arrow-down' : 'octicon-arrow-up'" @click="query.order = (query.order == 'asc') ? 'desc' : 'asc'"></span>
</button>
</div>
</div>
<div v-if="requests.length != 0" class="result">
<div class="list-headers">
<header class="level list-row list-row-head text-muted small">
<div class="level-left list-header-subject">
<div class="list-row-col ellipsis list-subject level ">
<span class="level-item">{{ columns[0].label }}</span>
</div>
<div class="list-row-col ellipsis hidden-xs" v-for="(column, index) in columns.slice(1)" :key="index" :class="{'text-right': column.number}">
<span>{{ column.label }}</span>
</div>
</div>
<div class="level-right">
<span class="list-count"><span>{{ (query.pagination.page - 1) * (query.pagination.limit) + 1 }} - {{ Math.min(query.pagination.page * query.pagination.limit, requests.length) }} of {{ requests.length }}</span></span>
</div>
</header>

</div>
<div class="result-list">
<div class="list-row-container" v-for="(request, index) in paginated(sorted(filtered(requests)))" :key="index" @click="route_to_request_detail(request.uuid)">
<div class="level list-row small">
<div class="level-left ellipsis">
<div class="list-row-col ellipsis list-subject level ">
<span class="level-item bold">
{{ request[columns[0].slug] }}
</span>
</div>
<div class="list-row-col ellipsis" v-for="(column, index) in columns.slice(1)" :key="index" :class="{'text-right': column.number}">
<span class="ellipsis text-muted">{{ request[column.slug] }}</span>
</div>
</div>
<div class="level-right ellipsis">
<div class="list-row-col ellipsis list-subject level ">
<span class="level-item ellipsis text-muted">

</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="requests.length == 0" class="no-result text-muted flex justify-center align-center" style="">
<div class="msg-box no-border" v-if="status.status == 'Inactive'" >
<p>Recorder is Inactive</p>
<p><button class="btn btn-primary btn-sm btn-new-doc" @click="start()">Start Recording</button></p>
</div>
<div class="msg-box no-border" v-if="status.status == 'Active'" >
<p>No Requests found</p>
<p>Go make some noise</p>
</div>
</div>
<div v-if="requests.length != 0" class="list-paging-area">
<div class="row">
<div class="col-xs-6">
<div class="btn-group btn-group-paging">
<button type="button" class="btn btn-default btn-sm" v-for="(limit, index) in [20, 100, 500]" :key="index" :class="query.pagination.limit == limit ? 'btn-info' : ''" @click="query.pagination.limit = limit">
{{ limit }}
</button>
</div>
</div>
<div class="col-xs-6 text-right">
<div class="btn-group btn-group-paging">
<button type="button" class="btn btn-default btn-sm" :class="page.status" v-for="(page, index) in pages" :key="index" @click="query.pagination.page = page.number">
{{ page.label }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

<script>
export default {
name: "RecorderDetail",
data() {
return {
requests: [],
columns: [
{label: "CMD", slug: "cmd"},
{label: "Duration (ms)", slug: "duration", sortable: true, number: true},
{label: "Time in Queries (ms)", slug: "time_queries", sortable: true, number: true},
{label: "Queries", slug: "queries", sortable: true, number: true},
{label: "Method", slug: "method"},
{label: "Time", slug: "time", sortable: true},
],
query: {
sort: "time",
order: "asc",
filters: {},
pagination: {
limit: 20,
page: 1,
total: 0,
}
},
status: {
color: "grey",
status: "Unknown",
},
};
},
created() {
let route = frappe.get_route();
if (route[2]) {
this.$router.push({name: 'request-detail', params: {id: route[2]}});
}
},
mounted() {
this.fetch_status();
this.refresh();
this.$root.page.set_secondary_action("Clear", () => {
frappe.set_route("recorder");
this.clear();
});

},
computed: {
pages: function() {
const current_page = this.query.pagination.page;
const total_pages = this.query.pagination.total;
return [{
label: "First",
number: 1,
status: (current_page == 1) ? "disabled" : "",
},{
label: "Previous",
number: Math.max(current_page - 1, 1),
status: (current_page == 1) ? "disabled" : "",
}, {
label: current_page,
number: current_page,
status: "btn-info",
}, {
label: "Next",
number: Math.min(current_page + 1, total_pages),
status: (current_page == total_pages) ? "disabled" : "",
}, {
label: "Last",
number: total_pages,
status: (current_page == total_pages) ? "disabled" : "",
}];
}
},
methods: {
filtered: function(requests) {
requests = requests.slice();
const filters = Object.entries(this.query.filters);
requests = requests.filter(
(r) => filters.map((f) => (r[f[0]] || "").match(f[1])).every(Boolean)
);
this.query.pagination.total = Math.ceil(requests.length / this.query.pagination.limit);
return requests;
},
paginated: function(requests) {
requests = requests.slice();
const begin = (this.query.pagination.page - 1) * (this.query.pagination.limit);
const end = begin + this.query.pagination.limit;
return requests.slice(begin, end);
},
sorted: function(requests) {
requests = requests.slice();
const order = (this.query.order == "asc") ? 1 : -1;
const sort = this.query.sort;
return requests.sort((a,b) => (a[sort] > b[sort]) ? order : -order);
},
refresh: function() {
frappe.call("frappe.recorder.get").then( r => this.requests = r.message);
},
update: function(message) {
this.requests.push(JSON.parse(message));
},
clear: function() {
frappe.call("frappe.recorder.delete").then(r => this.refresh());
},
start: function() {
frappe.call("frappe.recorder.start").then(r => this.fetch_status());
},
stop: function() {
frappe.call("frappe.recorder.stop").then(r => this.fetch_status());
},
fetch_status: function() {
frappe.call("frappe.recorder.status").then(r => this.update_status(r.message));
},
update_status: function(result) {
if(result) {
this.status = {status: "Active", color: "green"}
} else {
this.status = {status: "Inactive", color: "red"}
}
this.$root.page.set_indicator(this.status.status, this.status.color);
if(this.status.status == "Active") {
frappe.realtime.on("recorder-dump-event", this.update);
} else {
frappe.realtime.off("recorder-dump-event", this.update);
}

this.update_buttons();
},
update_buttons: function() {
if(this.status.status == "Active") {
this.$root.page.set_primary_action("Stop", () => {
this.stop();
});
} else {
this.$root.page.set_primary_action("Start", () => {
this.start();
});
}
},
route_to_request_detail(id) {
this.$router.push({name: 'request-detail', params: {id}});
}
}
};
</script>
<style>
.list-row .level-left {
flex: 8;
width: 100%;
}
</style>

+ 11
- 0
frappe/public/js/frappe/recorder/RecorderRoot.vue Целия файл

@@ -0,0 +1,11 @@
<template>
<div>
<router-view/>
</div>
</template>

<script>
export default {
name: "RecorderRoot",
};
</script>

+ 284
- 0
frappe/public/js/frappe/recorder/RequestDetail.vue Целия файл

@@ -0,0 +1,284 @@
<template>
<div>
<div class="row form-section visible-section shaded-section">
<div class="section-body">
<div class="form-column col-sm-12">
<form>
<div class="frappe-control" :data-fieldtype="column.type" v-for="(column, index) in columns" :key="index" :class="column.class">
<div class="form-group">
<div class="clearfix"><label class="control-label">{{ column.label }}</label></div>
<div class="control-value like-disabled-input" v-html="column.formatter ? column.formatter(request[column.slug]) : request[column.slug]"></div>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="row form-section visible-section">
<div class="col-sm-10">
<h6 class="form-section-heading uppercase">SQL Queries</h6>
</div>
<div class="col-sm-2 filter-list">
<div class="sort-selector">
<div class="dropdown"><a class="text-muted dropdown-toggle small" data-toggle="dropdown"><span class="dropdown-text">{{ table_columns.filter(c => c.slug == query.sort)[0].label }}</span></a>
<ul class="dropdown-menu">
<li v-for="(column, index) in table_columns.filter(c => c.sortable)" :key="index" @click="query.sort = column.slug"><a class="option">{{ column.label }}</a></li>
</ul>
</div>
<button class="btn btn-default btn-xs btn-order">
<span class="octicon text-muted" :class="query.order == 'asc' ? 'octicon-arrow-down' : 'octicon-arrow-up'" @click="query.order = (query.order == 'asc') ? 'desc' : 'asc'"></span>
</button>
</div>
</div>
<div class="section-body">
<div class="form-column col-sm-12">
<form>
<div class="form-group frappe-control input-max-width" data-fieldtype="Check">
<div class="checkbox">
<label>
<span class="input-area"><input type="checkbox" class="input-with-feedback bold" data-fieldtype="Check" v-model="group_duplicates"></span>
<span class="label-area small">Group Duplicate Queries</span>
</label>
</div>
</div>
<div class="frappe-control" data-fieldtype="Table">
<div>
<div class="form-grid">
<div class="grid-heading-row">
<div class="grid-row">
<div class="data-row row">
<div class="row-index col col-xs-1">
<span>Index</span></div>
<div class="col grid-static-col col-xs-6">
<div class="static-area ellipsis">Query</div>
</div>
<div class="col grid-static-col col-xs-2">
<div class="static-area ellipsis text-right">Duration (ms)</div>
</div>
<div class="col grid-static-col col-xs-2">
<div class="static-area ellipsis text-right">Exact Copies</div>
</div>
</div>
</div>
</div>
<div class="grid-body">
<div class="rows">
<div class="grid-row" :class="showing == call.index ? 'grid-row-open' : ''" v-for="call in paginated(sorted(grouped(request.calls)))" :key="call.index">
<div class="data-row row" v-if="showing != call.index" style="display: block;" @click="showing = call.index" >
<div class="row-index col col-xs-1"><span>{{ call.index }}</span></div>
<div class="col grid-static-col col-xs-6" data-fieldtype="Code">
<div class="static-area"><span>{{ call.query }}</span></div>
</div>
<div class="col grid-static-col col-xs-2">
<div class="static-area ellipsis text-right">{{ call.duration }}</div>
</div>
<div class="col grid-static-col col-xs-2">
<div class="static-area ellipsis text-right">{{ call.exact_copies }}</div>
</div>
<div class="col col-xs-1"><a class="close btn-open-row">
<span class="octicon octicon-triangle-down"></span></a>
</div>
</div>
<div class="form-in-grid">
<div class="grid-form-heading" @click="showing = null">
<div class="toolbar grid-header-toolbar">
<span class="panel-title">SQL Query #<span class="grid-form-row-index">{{ call.index }}</span></span>
<div class="btn btn-default btn-xs pull-right" style="margin-left: 7px;">
<span class="hidden-xs octicon octicon-triangle-up"></span>
</div>
</div>
</div>
<div class="grid-form-body">
<div class="form-area">
<div class="form-layout">
<div class="form-page">
<div class="row form-section visible-section">
<div class="section-body">
<div class="form-column col-sm-12">
<form>
<div class="frappe-control">
<div class="form-group">
<div class="clearfix"><label class="control-label">Query</label></div>
<div class="control-value like-disabled-input for-description"><pre>{{ call.query }}</pre></div>
</div>
</div>
<div class="frappe-control input-max-width">
<div class="form-group">
<div class="clearfix"><label class="control-label">Duration (ms)</label></div>
<div class="control-value like-disabled-input">{{ call.duration }}</div>
</div>
</div>
<div class="frappe-control input-max-width">
<div class="form-group">
<div class="clearfix"><label class="control-label">Exact Copies</label></div>
<div class="control-value like-disabled-input">{{ call.exact_copies }}</div>
</div>
</div>
<div class="frappe-control">
<div class="form-group">
<div class="clearfix"><label class="control-label">Stack Trace</label></div>
<div class="control-value like-disabled-input for-description"><pre>{{ call.stack }}</pre></div>
</div>
</div>
<div class="frappe-control" v-if="call.explain_result[0]">
<div class="form-group">
<div class="clearfix"><label class="control-label">SQL Explain</label></div>
<div class="control-value like-disabled-input for-description" style="overflow:auto">
<table class="table table-striped">
<thead>
<tr>
<th v-for="key in Object.keys(call.explain_result[0])" :key="key">{{ key }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in call.explain_result" :key="index">
<td v-for="key in Object.keys(call.explain_result[0])" :key="key">{{ row[key] }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="request.calls.length == 0" class="grid-empty text-center">No Data</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<div v-if="request.calls.length != 0" class="list-paging-area" style="border-top: none">
<div class="row">
<div class="col-xs-6">
<div class="btn-group btn-group-paging">
<button type="button" class="btn btn-default btn-sm" v-for="(limit, index) in [20, 50, 100]" :key="index" :class="query.pagination.limit == limit ? 'btn-info' : ''" @click="query.pagination.limit = limit">
{{ limit }}
</button>
</div>
</div>
<div class="col-xs-6 text-right">
<div class="btn-group btn-group-paging">
<button type="button" class="btn btn-default btn-sm" :class="page.status" v-for="(page, index) in pages" :key="index" @click="query.pagination.page = page.number">
{{ page.label }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

<script>
export default {
name: "RequestDetail",
data() {
return {
columns: [
{label: "Path", slug: "path", type: "Data", class: "col-sm-6"},
{label: "CMD", slug: "cmd", type: "Data", class: "col-sm-6"},
{label: "Time", slug: "time", type: "Time", class: "col-sm-6"},
{label: "Duration (ms)", slug: "duration", type: "Float", class: "col-sm-6"},
{label: "Number of Queries", slug: "queries", type: "Int", class: "col-sm-6"},
{label: "Time in Queries (ms)", slug: "time_queries", type: "Float", class: "col-sm-6"},
{label: "Request Headers", slug: "headers", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
{label: "Form Dict", slug: "form_dict", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
],
table_columns: [
{label: "Execution Order", slug: "index", sortable: true},
{label: "Duration (ms)", slug: "duration", sortable: true},
{label: "Exact Copies", slug: "exact_copies", sortable: true},
],
query: {
sort: "index",
order: "asc",
pagination: {
limit: 20,
page: 1,
total: 0,
}
},
group_duplicates: false,
showing: null,
request: {
calls: [],
},
};
},
computed: {
pages: function() {
const current_page = this.query.pagination.page;
const total_pages = this.query.pagination.total;
return [{
label: "First",
number: 1,
status: (current_page == 1) ? "disabled" : "",
},{
label: "Previous",
number: Math.max(current_page - 1, 1),
status: (current_page == 1) ? "disabled" : "",
}, {
label: current_page,
number: current_page,
status: "btn-info",
}, {
label: "Next",
number: Math.min(current_page + 1, total_pages),
status: (current_page == total_pages) ? "disabled" : "",
}, {
label: "Last",
number: total_pages,
status: (current_page == total_pages) ? "disabled" : "",
}];
}
},
methods: {
paginated: function(calls) {
calls = calls.slice();
this.query.pagination.total = Math.ceil(calls.length / this.query.pagination.limit);
const begin = (this.query.pagination.page - 1) * (this.query.pagination.limit);
const end = begin + this.query.pagination.limit;
return calls.slice(begin, end);
},
sorted: function(calls) {
calls = calls.slice();
const order = (this.query.order == "asc") ? 1 : -1;
const sort = this.query.sort;
return calls.sort((a,b) => (a[sort] > b[sort]) ? order : -order);
},
grouped: function(calls) {
if(this.group_duplicates) {
calls = calls.slice();
return calls.uniqBy(call => call["query"]);
}
return calls
},
},
mounted() {
frappe.breadcrumbs.add({
type: 'Custom',
label: __('Recorder'),
route: '#recorder'
});
frappe.call({
method: "frappe.recorder.get",
args: {
uuid: this.$route.params.id
}
}).then( r => {
this.request = r.message
});
}
};
</script>

+ 39
- 0
frappe/public/js/frappe/recorder/recorder.js Целия файл

@@ -0,0 +1,39 @@
import Vue from 'vue/dist/vue.js';
import VueRouter from 'vue-router/dist/vue-router.js';

import RecorderRoot from "./RecorderRoot.vue";

import RecorderDetail from "./RecorderDetail.vue";
import RequestDetail from "./RequestDetail.vue";

Vue.use(VueRouter);
const routes = [
{
name: "recorder-detail",
path: '/desk',
component: RecorderDetail,
},
{
name: "request-detail",
path: '/request/:id',
component: RequestDetail,
},
];

const router = new VueRouter({
mode: 'history',
base: "/desk#recorder/",
routes: routes,
});

new Vue({
el: ".recorder-container",
router: router,
data: {
page: cur_page.page.page
},
template: "<recorder-root/>",
components: {
RecorderRoot,
}
});

+ 170
- 0
frappe/recorder.py Целия файл

@@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals

from collections import Counter
import datetime
import json
import re
import time
import traceback
import frappe
import sqlparse


RECORDER_INTERCEPT_FLAG = "recorder-intercept"
RECORDER_REQUEST_SPARSE_HASH = "recorder-requests-sparse"
RECORDER_REQUEST_HASH = "recorder-requests"


def sql(*args, **kwargs):
start_time = time.time()
result = frappe.db._sql(*args, **kwargs)
end_time = time.time()

stack_frames = filter(lambda x: "/apps/" in x, traceback.format_stack()[:-1])
stack_frames = map(lambda x: re.sub("File \".*/apps/", "File \"", x), stack_frames)
stack = "".join(stack_frames)

if frappe.conf.db_type == 'postgres':
query = frappe.db._cursor.query
else:
query = frappe.db._cursor._executed

query = sqlparse.format(query.strip(), keyword_case="upper", reindent=True)

# Collect EXPLAIN for executed query
if query.lower().strip().split()[0] in ("select", "update", "delete"):
# Only SELECT/UPDATE/DELETE queries can be "EXPLAIN"ed
explain_result = frappe.db._sql("EXPLAIN {}".format(query), as_dict=True)
else:
explain_result = []

data = {
"query": query,
"stack": stack,
"explain_result": explain_result,
"time": start_time,
"duration": float("{:.3f}".format((end_time - start_time) * 1000)),
}

frappe.local._recorder.register(data)
return result


def record():
if __debug__:
if frappe.cache().get_value(RECORDER_INTERCEPT_FLAG):
frappe.local._recorder = Recorder()


def dump():
if __debug__:
if hasattr(frappe.local, "_recorder"):
frappe.local._recorder.dump()


class Recorder():
def __init__(self):
self.uuid = frappe.generate_hash(length=10)
self.time = datetime.datetime.now()
self.calls = []
self.path = frappe.request.path
self.cmd = frappe.local.form_dict.cmd or ""
self.method = frappe.request.method
self.headers = dict(frappe.local.request.headers)
self.form_dict = frappe.local.form_dict
_patch()

def register(self, data):
self.calls.append(data)

def dump(self):
request_data = {
"uuid": self.uuid,
"path": self.path,
"cmd": self.cmd,
"time": self.time,
"queries": len(self.calls),
"time_queries": float("{:0.3f}".format(sum(call["duration"] for call in self.calls))),
"duration": float("{:0.3f}".format((datetime.datetime.now() - self.time).total_seconds() * 1000)),
"method": self.method,
}
frappe.cache().hset(RECORDER_REQUEST_SPARSE_HASH, self.uuid, request_data)
frappe.publish_realtime(event="recorder-dump-event", message=json.dumps(request_data, default=str))

self.mark_duplicates()

request_data["calls"] = self.calls
request_data["headers"] = self.headers
request_data["form_dict"] = self.form_dict
frappe.cache().hset(RECORDER_REQUEST_HASH, self.uuid, request_data)

def mark_duplicates(self):
counts = Counter([call["query"] for call in self.calls])
for index, call in enumerate(self.calls):
call["index"] = index
call["exact_copies"] = counts[call["query"]]


def _patch():
frappe.db._sql = frappe.db.sql
frappe.db.sql = sql


def do_not_record(function):
def wrapper(*args, **kwargs):
if hasattr(frappe.local, "_recorder"):
del frappe.local._recorder
frappe.db.sql = frappe.db._sql
return function(*args, **kwargs)
return wrapper


def administrator_only(function):
def wrapper(*args, **kwargs):
if frappe.session.user != "Administrator":
frappe.throw(_("Only Administrator is allowed to use Recorder"))
return function(*args, **kwargs)
return wrapper


@frappe.whitelist()
@do_not_record
@administrator_only
def status(*args, **kwargs):
return bool(frappe.cache().get_value(RECORDER_INTERCEPT_FLAG))


@frappe.whitelist()
@do_not_record
@administrator_only
def start(*args, **kwargs):
frappe.cache().set_value(RECORDER_INTERCEPT_FLAG, 1)


@frappe.whitelist()
@do_not_record
@administrator_only
def stop(*args, **kwargs):
frappe.cache().delete_value(RECORDER_INTERCEPT_FLAG)


@frappe.whitelist()
@do_not_record
@administrator_only
def get(uuid=None, *args, **kwargs):
if uuid:
result = frappe.cache().hget(RECORDER_REQUEST_HASH, uuid)
else:
result = list(frappe.cache().hgetall(RECORDER_REQUEST_SPARSE_HASH).values())
return result


@frappe.whitelist()
@do_not_record
@administrator_only
def delete(*args, **kwargs):
frappe.cache().delete_value(RECORDER_REQUEST_SPARSE_HASH)
frappe.cache().delete_value(RECORDER_REQUEST_HASH)

+ 121
- 0
frappe/tests/test_recorder.py Целия файл

@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-

# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt

from __future__ import unicode_literals
import unittest
import frappe
import frappe.recorder
from .test_website import set_request

import sqlparse

class TestRecorder(unittest.TestCase):
def setUp(self):
frappe.recorder.stop()
frappe.recorder.delete()
set_request()
frappe.recorder.start()
frappe.recorder.record()

def test_start(self):
frappe.recorder.dump()
requests = frappe.recorder.get()
self.assertEqual(len(requests), 1)

def test_do_not_record(self):
frappe.recorder.do_not_record(frappe.get_all)('DocType')
frappe.recorder.dump()
requests = frappe.recorder.get()
self.assertEqual(len(requests), 0)

def test_get(self):
frappe.recorder.dump()

requests = frappe.recorder.get()
self.assertEqual(len(requests), 1)

request = frappe.recorder.get(requests[0]['uuid'])
self.assertTrue(request)

def test_delete(self):
frappe.recorder.dump()

requests = frappe.recorder.get()
self.assertEqual(len(requests), 1)

frappe.recorder.delete()

requests = frappe.recorder.get()
self.assertEqual(len(requests), 0)

def test_record_without_sql_queries(self):
frappe.recorder.dump()

requests = frappe.recorder.get()
request = frappe.recorder.get(requests[0]['uuid'])

self.assertEqual(len(request['calls']), 0)

def test_record_with_sql_queries(self):
frappe.get_all('DocType')
frappe.recorder.dump()

requests = frappe.recorder.get()
request = frappe.recorder.get(requests[0]['uuid'])

self.assertNotEqual(len(request['calls']), 0)

def test_explain(self):
frappe.db.sql('SELECT * FROM tabDocType')
frappe.db.sql('COMMIT')
frappe.recorder.dump()

requests = frappe.recorder.get()
request = frappe.recorder.get(requests[0]['uuid'])

self.assertEqual(len(request['calls'][0]['explain_result']), 1)
self.assertEqual(len(request['calls'][1]['explain_result']), 0)


def test_multiple_queries(self):
queries = [
{'mariadb': 'SELECT * FROM tabDocType', 'postgres': 'SELECT * FROM "tabDocType"'},
{'mariadb': 'SELECT COUNT(*) FROM tabDocType', 'postgres': 'SELECT COUNT(*) FROM "tabDocType"'},
{'mariadb': 'COMMIT', 'postgres': 'COMMIT'},
]

sql_dialect = frappe.conf.db_type or 'mariadb'
for query in queries:
frappe.db.sql(query[sql_dialect])

frappe.recorder.dump()

requests = frappe.recorder.get()
request = frappe.recorder.get(requests[0]['uuid'])

self.assertEqual(len(request['calls']), len(queries))

for query, call in zip(queries, request['calls']):
self.assertEqual(call['query'], sqlparse.format(query[sql_dialect].strip(), keyword_case='upper', reindent=True))

def test_duplicate_queries(self):
queries = [
('SELECT * FROM tabDocType', 2),
('SELECT COUNT(*) FROM tabDocType', 1),
('select * from tabDocType', 2),
('COMMIT', 3),
('COMMIT', 3),
('COMMIT', 3),
]
for query in queries:
frappe.db.sql(query[0])

frappe.recorder.dump()

requests = frappe.recorder.get()
request = frappe.recorder.get(requests[0]['uuid'])

for query, call in zip(queries, request['calls']):
self.assertEqual(call['exact_copies'], query[1])

+ 3
- 2
package.json Целия файл

@@ -35,7 +35,8 @@
"socket.io": "^2.0.4",
"superagent": "^3.8.2",
"touch": "^3.1.0",
"vue": "^2.5.17"
"vue": "^2.5.17",
"vue-router": "^2.0.0"
},
"devDependencies": {
"babel-runtime": "^6.26.0",
@@ -50,7 +51,7 @@
"rollup-plugin-node-resolve": "^3.0.2",
"rollup-plugin-postcss": "^1.4.0",
"rollup-plugin-uglify": "^3.0.0",
"rollup-plugin-vue": "^4.3.2",
"rollup-plugin-vue": "4.2.0",
"vue-template-compiler": "^2.5.17"
}
}

+ 2
- 1
requirements.txt Целия файл

@@ -59,4 +59,5 @@ coverage
urllib3
GitPython==2.1.11
psycopg2==2.7.5
psycopg2-binary==2.7.5
psycopg2-binary==2.7.5
sqlparse==0.2.4

+ 857
- 949
yarn.lock
Файловите разлики са ограничени, защото са твърде много
Целия файл


Зареждане…
Отказ
Запис